diff --git a/README.md b/README.md index a3bba4d..f0a5150 100644 --- a/README.md +++ b/README.md @@ -18,10 +18,10 @@ Download the binary for your platform from the [Releases](https://github.com/loo ```bash # Add a remote endpoint -graphql-cli add production --url https://api.example.com/graphql +graphql-cli endpoint add production --url https://api.example.com/graphql # Add a local schema endpoint -graphql-cli add local --schema-file ./schema.graphql +graphql-cli endpoint add local --schema-file ./schema.graphql # Execute a query graphql-cli query -e production '{ users { id name } }' @@ -32,27 +32,47 @@ graphql-cli find -e production user ## Commands -### `add` — Add a new endpoint +### `endpoint` — Manage endpoints ```bash -graphql-cli add --url [--schema-file ] [-d ] [--header key=value] +graphql-cli endpoint +``` + +Available subcommands: + +- `add` — add a new endpoint +- `list` — list configured endpoints +- `update` — update an existing endpoint +- `login` — store credentials for an endpoint +- `logout` — remove stored credentials for an endpoint + +### `endpoint add` — Add a new endpoint + +```bash +graphql-cli endpoint add --url [--schema-file ] [-d ] [--header key=value] ``` **Examples:** ```bash -graphql-cli add production --url https://api.example.com/graphql --header "Authorization=Bearer token" -graphql-cli add local --schema-file ./schema.graphql --description "Local dev schema" +graphql-cli endpoint add production --url https://api.example.com/graphql --header "Authorization=Bearer token" +graphql-cli endpoint add local --schema-file ./schema.graphql --description "Local dev schema" ``` -### `list` — List configured endpoints +### `endpoint list` — List configured endpoints ```bash -graphql-cli list [--detail] +graphql-cli endpoint list [--detail] ``` Use `--detail` to show headers, schema file paths, and auth status. +Example: + +```bash +graphql-cli endpoint list --detail +``` + ### `query` — Execute a GraphQL query ```bash @@ -81,18 +101,18 @@ graphql-cli mutate -e production 'mutation { createUser(name: "test") { id } }' graphql-cli mutate -e production -f mutation.graphql -v '{"name": "test"}' ``` -### `update` — Update an existing endpoint +### `endpoint update` — Update an existing endpoint ```bash -graphql-cli update [--url ] [-d ] [--header key=value] +graphql-cli endpoint update [--url ] [-d ] [--header key=value] ``` **Examples:** ```bash -graphql-cli update production --url https://api.example.com/v2/graphql -graphql-cli update production --header "Authorization=Bearer new-token" -graphql-cli update production --url https://new-url.com/graphql --header "X-Custom=value" -d "Updated endpoint" +graphql-cli endpoint update production --url https://api.example.com/v2/graphql +graphql-cli endpoint update production --header "Authorization=Bearer new-token" +graphql-cli endpoint update production --url https://new-url.com/graphql --header "X-Custom=value" -d "Updated endpoint" ``` ### `find` — Search schema definitions @@ -116,12 +136,12 @@ graphql-cli find -e production status --enum graphql-cli find -e production user --detail ``` -### `login` — Authenticate with an endpoint +### `endpoint login` — Authenticate with an endpoint Credentials are stored in the OS keyring (macOS Keychain, Windows Credential Manager, GNOME Keyring) with a plaintext file fallback. ```bash -graphql-cli login [endpoint] [-e ] [--type token|basic|header] +graphql-cli endpoint login [--type token|basic|header] ``` **Supported auth types:** @@ -135,26 +155,76 @@ graphql-cli login [endpoint] [-e ] [--type token|basic|header] **Examples:** ```bash -graphql-cli login production -graphql-cli login production --type token --token "my-token" +graphql-cli endpoint login production +graphql-cli endpoint login production --type token --token "my-token" +``` + +### `endpoint logout` — Remove stored credentials + +```bash +graphql-cli endpoint logout ``` -### `logout` — Remove stored credentials +### `audit list` — List recorded queries and mutations ```bash -graphql-cli logout [endpoint] [-e ] +graphql-cli audit list [--endpoint ] [--status success|error] [--contains ] [--query|--mutation] [--detail] [--limit ] +``` + +Examples: + +```bash +graphql-cli audit list +graphql-cli audit list --endpoint production +graphql-cli audit list --status error +graphql-cli audit list --contains createUser +graphql-cli audit list --mutation --detail ``` ## Configuration The configuration file is stored at `~/.config/graphql-cli/config.yaml` by default. Use `--config` to specify a custom path. +## Audit Log + +Executed GraphQL statements are appended to `~/.config/graphql-cli/audit.log` as JSON lines. + +Each line records: + +- `timestamp` +- `endpoint` +- `url` +- `status` +- `statement` +- `error` when execution fails + +Example entry: + +```json +{"timestamp":"2026-03-29T08:15:30.123456Z","endpoint":"production","url":"https://api.example.com/graphql","status":"success","statement":"query { viewer { id } }"} +``` + +You can inspect recorded operations with: + +```bash +graphql-cli audit list +graphql-cli audit list --query +graphql-cli audit list --status error +graphql-cli audit list --contains viewer +graphql-cli audit list --mutation --detail +``` + +Or stream the raw log with: + +```bash +tail -f ~/.config/graphql-cli/audit.log +``` + ## Global Flags | Flag | Description | |-----------------------|--------------------------------------| | `--config ` | Config file path | -| `-e, --endpoint ` | Endpoint name to use | ## License diff --git a/SKILL.md b/SKILL.md index 1d1a600..50bad92 100644 --- a/SKILL.md +++ b/SKILL.md @@ -37,34 +37,36 @@ go install github.com/looplj/graphql-cli@latest ## Workflows +Endpoint management commands live under `graphql-cli endpoint ...`. + ### 1. Add an endpoint **Remote URL:** ```bash -graphql-cli add --url [--description "desc"] [--header "Key=Value"] +graphql-cli endpoint add --url [--description "desc"] [--header "Key=Value"] ``` **Local schema file:** ```bash -graphql-cli add --schema-file ./schema.graphql [--description "desc"] +graphql-cli endpoint add --schema-file ./schema.graphql [--description "desc"] ``` Example: ```bash -graphql-cli add production --url https://api.example.com/graphql --description "Prod API" -graphql-cli add local --schema-file ./testdata/schema.graphql --description "Local schema" +graphql-cli endpoint add production --url https://api.example.com/graphql --description "Prod API" +graphql-cli endpoint add local --schema-file ./testdata/schema.graphql --description "Local schema" ``` ### 2. Update an endpoint ```bash -graphql-cli update --url [--description "desc"] [--header "Key=Value"] +graphql-cli endpoint update --url [--description "desc"] [--header "Key=Value"] ``` Example: ```bash -graphql-cli update production --url https://api.example.com/v2/graphql -graphql-cli update production --header "Authorization=Bearer new-token" -d "Updated prod API" +graphql-cli endpoint update production --url https://api.example.com/v2/graphql +graphql-cli endpoint update production --header "Authorization=Bearer new-token" -d "Updated prod API" ``` Headers are merged — existing headers not specified in the update are preserved. @@ -72,20 +74,20 @@ Headers are merged — existing headers not specified in the update are preserve ### 3. List endpoints ```bash -graphql-cli list # names and URLs -graphql-cli list --detail # includes headers (masked) and auth status +graphql-cli endpoint list # names and URLs +graphql-cli endpoint list --detail # includes headers (masked) and auth status ``` ### 4. Authenticate ```bash -graphql-cli login --type token --token "my-api-key" -graphql-cli login --type basic --user admin --pass secret -graphql-cli login --type header --key X-API-Key --value "key123" -graphql-cli login -e production --type token --token "my-token" +graphql-cli endpoint login --type token --token "my-api-key" +graphql-cli endpoint login --type basic --user admin --pass secret +graphql-cli endpoint login --type header --key X-API-Key --value "key123" +graphql-cli endpoint login -e production --type token --token "my-token" # Remove credentials -graphql-cli logout +graphql-cli endpoint logout ``` Credentials are stored in the OS keyring (macOS Keychain, Windows Credential Manager, GNOME Keyring) with a plaintext file fallback. @@ -145,6 +147,27 @@ When executing queries/mutations, headers are merged with this priority (highest 2. Stored credentials (`login`) 3. Config file headers +Each executed GraphQL statement is also appended to `~/.config/graphql-cli/audit.log` as a JSON line containing timestamp, endpoint, status, and the statement text. + +Example: + +```json +{"timestamp":"2026-03-29T08:15:30.123456Z","endpoint":"production","url":"https://api.example.com/graphql","status":"success","statement":"query { viewer { id } }"} +``` + +To inspect the log stream locally: + +```bash +graphql-cli audit list +graphql-cli audit list --query +graphql-cli audit list --status error +graphql-cli audit list --contains createUser +graphql-cli audit list --mutation --detail + +# Or stream the raw log file +tail -f ~/.config/graphql-cli/audit.log +``` + ## Common patterns ### Query with variables from a file @@ -174,4 +197,4 @@ graphql-cli mutate 'mutation { createUser(input: {name: "Alice", email: "alice@e ## Guidelines -- **Always use `find` without `--detail` first** to get an overview of matching names, then use `find --detail` on specific results to see full definitions with fields and arguments. This avoids overwhelming output when schemas are large. \ No newline at end of file +- **Always use `find` without `--detail` first** to get an overview of matching names, then use `find --detail` on specific results to see full definitions with fields and arguments. This avoids overwhelming output when schemas are large. diff --git a/cmd/add.go b/cmd/add.go index 7f6cb1f..0da268c 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -9,18 +9,6 @@ import ( "github.com/looplj/graphql-cli/internal/config" ) -var addCmd = &cobra.Command{ - Use: "add ", - Short: "Add a new endpoint to the configuration", - Long: `Add a new GraphQL endpoint. Specify either a remote URL or a local schema file. - -Examples: - graphql-cli add production --url https://api.example.com/graphql --header "Authorization=Bearer token" - graphql-cli add local --schema-file ./schema.graphql --description "Local dev schema"`, - Args: cobra.ExactArgs(1), - RunE: runAdd, -} - var ( addURL string addSchemaFile string @@ -29,11 +17,28 @@ var ( ) func init() { - addCmd.Flags().StringVar(&addURL, "url", "", "GraphQL endpoint URL") - addCmd.Flags().StringVar(&addSchemaFile, "schema-file", "", "path to local GraphQL schema file") - addCmd.Flags().StringVarP(&addDescription, "description", "d", "", "endpoint description") - addCmd.Flags().StringSliceVar(&addHeaders, "header", nil, "HTTP headers (key=value), can be specified multiple times") - rootCmd.AddCommand(addCmd) + endpointCmd.AddCommand(newAddCmd()) +} + +func newAddCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "add ", + Short: "Add a new endpoint to the configuration", + Long: `Add a new GraphQL endpoint. Specify either a remote URL or a local schema file. + +Examples: + graphql-cli endpoint add production --url https://api.example.com/graphql --header "Authorization=Bearer token" + graphql-cli endpoint add local --schema-file ./schema.graphql --description "Local dev schema"`, + Args: cobra.ExactArgs(1), + RunE: runAdd, + } + + cmd.Flags().StringVar(&addURL, "url", "", "GraphQL endpoint URL") + cmd.Flags().StringVar(&addSchemaFile, "schema-file", "", "path to local GraphQL schema file") + cmd.Flags().StringVarP(&addDescription, "description", "d", "", "endpoint description") + cmd.Flags().StringSliceVar(&addHeaders, "header", nil, "HTTP headers (key=value), can be specified multiple times") + + return cmd } func runAdd(cmd *cobra.Command, args []string) error { diff --git a/cmd/audit.go b/cmd/audit.go new file mode 100644 index 0000000..200b4ef --- /dev/null +++ b/cmd/audit.go @@ -0,0 +1,195 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/fatih/color" + "github.com/spf13/cobra" + + "github.com/looplj/graphql-cli/internal/audit" +) + +var auditCmd = &cobra.Command{ + Use: "audit", + Short: "Inspect recorded GraphQL operations", + Long: "Inspect the local audit log of executed GraphQL queries and mutations.", +} + +var ( + auditListDetail bool + auditListEndpoint string + auditListContains string + auditListLimit int + auditListQuery bool + auditListMutation bool + auditListStatus string +) + +func init() { + auditCmd.AddCommand(newAuditListCmd()) + rootCmd.AddCommand(auditCmd) +} + +func newAuditListCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List recorded GraphQL queries and mutations", + Args: cobra.NoArgs, + RunE: runAuditList, + } + + cmd.Flags().BoolVar(&auditListDetail, "detail", false, "show the full GraphQL statement") + cmd.Flags().StringVar(&auditListContains, "contains", "", "filter by text contained in statement, endpoint, URL, or error") + cmd.Flags().StringVar(&auditListEndpoint, "endpoint", "", "filter by endpoint name") + cmd.Flags().IntVar(&auditListLimit, "limit", 10, "limit results to the most recent N entries (0 = all)") + cmd.Flags().BoolVar(&auditListQuery, "query", false, "show only queries") + cmd.Flags().BoolVar(&auditListMutation, "mutation", false, "show only mutations") + cmd.Flags().StringVar(&auditListStatus, "status", "", "filter by status: success or error") + + return cmd +} + +func runAuditList(cmd *cobra.Command, args []string) error { + if err := validateAuditStatus(auditListStatus); err != nil { + return err + } + + if auditListLimit < 0 { + return fmt.Errorf("--limit must be zero or greater") + } + + entries, err := audit.ReadEntries() + if err != nil { + return err + } + + filtered := make([]audit.Entry, 0, len(entries)) + for _, entry := range entries { + if !matchesAuditFilters(entry) { + continue + } + + filtered = append(filtered, entry) + } + + if len(filtered) == 0 { + fmt.Println("No audit entries found.") + return nil + } + + start := 0 + if auditListLimit > 0 && len(filtered) > auditListLimit { + start = len(filtered) - auditListLimit + } + + kindColor := color.New(color.FgCyan, color.Bold) + successColor := color.New(color.FgGreen, color.Bold) + errorColor := color.New(color.FgRed, color.Bold) + dimColor := color.New(color.FgHiBlack) + + for i := len(filtered) - 1; i >= start; i-- { + entry := filtered[i] + kind := strings.ToUpper(audit.StatementKind(entry.Statement)) + + kindColor.Printf("[%s] ", kind) + fmt.Printf("%s ", entry.Endpoint) + + if entry.Status == "error" { + errorColor.Printf("%s ", strings.ToUpper(entry.Status)) + } else { + successColor.Printf("%s ", strings.ToUpper(entry.Status)) + } + + dimColor.Println(entry.Timestamp) + + statement := summarizeStatement(entry.Statement) + if auditListDetail { + statement = strings.TrimSpace(entry.Statement) + } + + fmt.Printf(" %s\n", statement) + + if entry.Error != "" { + errorColor.Printf(" error: %s\n", entry.Error) + } + + if i > start { + fmt.Println() + } + } + + return nil +} + +func includeAuditKind(kind string) bool { + if auditListQuery || auditListMutation { + return (auditListQuery && kind == "query") || (auditListMutation && kind == "mutation") + } + + return kind == "query" || kind == "mutation" +} + +func matchesAuditFilters(entry audit.Entry) bool { + if !includeAuditKind(audit.StatementKind(entry.Statement)) { + return false + } + + if auditListEndpoint != "" && entry.Endpoint != auditListEndpoint { + return false + } + + if auditListStatus != "" && !strings.EqualFold(entry.Status, auditListStatus) { + return false + } + + if auditListContains != "" && !auditEntryContains(entry, auditListContains) { + return false + } + + return true +} + +func validateAuditStatus(status string) error { + if status == "" { + return nil + } + + if strings.EqualFold(status, "success") || strings.EqualFold(status, "error") { + return nil + } + + return fmt.Errorf("invalid --status %q: must be success or error", status) +} + +func auditEntryContains(entry audit.Entry, needle string) bool { + needle = strings.ToLower(strings.TrimSpace(needle)) + if needle == "" { + return true + } + + haystacks := []string{ + entry.Endpoint, + entry.URL, + entry.Status, + entry.Statement, + entry.Error, + } + + for _, haystack := range haystacks { + if strings.Contains(strings.ToLower(haystack), needle) { + return true + } + } + + return false +} + +func summarizeStatement(statement string) string { + compact := strings.Join(strings.Fields(strings.TrimSpace(statement)), " ") + if len(compact) <= 120 { + return compact + } + + return compact[:117] + "..." +} diff --git a/cmd/audit_test.go b/cmd/audit_test.go new file mode 100644 index 0000000..f2a955f --- /dev/null +++ b/cmd/audit_test.go @@ -0,0 +1,54 @@ +package cmd + +import ( + "testing" + + "github.com/looplj/graphql-cli/internal/audit" +) + +func TestMatchesAuditFilters(t *testing.T) { + entry := audit.Entry{ + Endpoint: "production", + URL: "https://api.example.com/graphql", + Status: "error", + Statement: "mutation CreateUser { createUser(name: \"Amp\") { id } }", + Error: "HTTP 401", + } + + auditListEndpoint = "production" + auditListMutation = true + auditListQuery = false + auditListStatus = "error" + auditListContains = "createuser" + + if !matchesAuditFilters(entry) { + t.Fatal("expected entry to match combined filters") + } + + auditListContains = "viewer" + + if matchesAuditFilters(entry) { + t.Fatal("expected entry not to match when contains filter misses") + } + + auditListContains = "" + auditListStatus = "success" + + if matchesAuditFilters(entry) { + t.Fatal("expected entry not to match mismatched status") + } +} + +func TestValidateAuditStatus(t *testing.T) { + if err := validateAuditStatus("success"); err != nil { + t.Fatalf("expected success to be valid: %v", err) + } + + if err := validateAuditStatus("error"); err != nil { + t.Fatalf("expected error to be valid: %v", err) + } + + if err := validateAuditStatus("pending"); err == nil { + t.Fatal("expected invalid status to return an error") + } +} diff --git a/cmd/endpoint.go b/cmd/endpoint.go new file mode 100644 index 0000000..79b5285 --- /dev/null +++ b/cmd/endpoint.go @@ -0,0 +1,13 @@ +package cmd + +import "github.com/spf13/cobra" + +var endpointCmd = &cobra.Command{ + Use: "endpoint", + Aliases: []string{"endpoints"}, + Short: "Manage configured GraphQL endpoints", +} + +func init() { + rootCmd.AddCommand(endpointCmd) +} diff --git a/cmd/find.go b/cmd/find.go index 167f3e1..1f78731 100644 --- a/cmd/find.go +++ b/cmd/find.go @@ -45,6 +45,7 @@ var ( ) func init() { + addEndpointFlag(findCmd) findCmd.Flags().BoolVar(&findQuery, "query", false, "search only Query fields") findCmd.Flags().BoolVar(&findMutation, "mutation", false, "search only Mutation fields") findCmd.Flags().BoolVar(&findType, "type", false, "search only Object/Interface/Union/Scalar types") diff --git a/cmd/list.go b/cmd/list.go index e4f59c9..5973642 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -11,18 +11,23 @@ import ( "github.com/looplj/graphql-cli/internal/config" ) -var listCmd = &cobra.Command{ - Use: "list", - Short: "List all configured endpoints", - Args: cobra.NoArgs, - RunE: runList, -} - var listDetail bool func init() { - listCmd.Flags().BoolVar(&listDetail, "detail", false, "show endpoint details including headers and auth") - rootCmd.AddCommand(listCmd) + endpointCmd.AddCommand(newListCmd()) +} + +func newListCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List all configured endpoints", + Args: cobra.NoArgs, + RunE: runList, + } + + cmd.Flags().BoolVar(&listDetail, "detail", false, "show endpoint details including headers and auth") + + return cmd } func runList(cmd *cobra.Command, args []string) error { @@ -32,7 +37,7 @@ func runList(cmd *cobra.Command, args []string) error { } if len(cfg.Endpoints) == 0 { - fmt.Println("No endpoints configured. Use 'graphql-cli add' to add one.") + fmt.Println("No endpoints configured. Use 'graphql-cli endpoint add' to add one.") return nil } diff --git a/cmd/login.go b/cmd/login.go index 380cb7c..f1e47a9 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -14,10 +14,24 @@ import ( "github.com/looplj/graphql-cli/internal/config" ) -var loginCmd = &cobra.Command{ - Use: "login [endpoint]", - Short: "Authenticate with a GraphQL endpoint", - Long: `Store credentials for a GraphQL endpoint. Credentials are saved +var ( + loginType string + loginToken string + loginUser string + loginPass string + loginKey string + loginValue string +) + +func init() { + endpointCmd.AddCommand(newLoginCmd()) +} + +func newLoginCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "login ", + Short: "Authenticate with a GraphQL endpoint", + Long: `Store credentials for a GraphQL endpoint. Credentials are saved in the OS keyring (macOS Keychain, Windows Credential Manager, GNOME Keyring) with a plaintext file fallback. @@ -27,31 +41,21 @@ Supported auth types: header - Custom header key=value Examples: - graphql-cli login # login to default endpoint - graphql-cli login production # login to specific endpoint - graphql-cli login production --type token # non-interactive with --token flag - graphql-cli login production --type token --token "my-token"`, - Args: cobra.MaximumNArgs(1), - RunE: runLogin, -} + graphql-cli endpoint login production + graphql-cli endpoint login production --type token # non-interactive with --token flag + graphql-cli endpoint login production --type token --token "my-token"`, + Args: cobra.ExactArgs(1), + RunE: runLogin, + } -var ( - loginType string - loginToken string - loginUser string - loginPass string - loginKey string - loginValue string -) + cmd.Flags().StringVar(&loginType, "type", "", "auth type: token, basic, header") + cmd.Flags().StringVar(&loginToken, "token", "", "bearer token (for --type token)") + cmd.Flags().StringVar(&loginUser, "user", "", "username (for --type basic)") + cmd.Flags().StringVar(&loginPass, "pass", "", "password (for --type basic)") + cmd.Flags().StringVar(&loginKey, "key", "", "header key (for --type header)") + cmd.Flags().StringVar(&loginValue, "value", "", "header value (for --type header)") -func init() { - loginCmd.Flags().StringVar(&loginType, "type", "", "auth type: token, basic, header") - loginCmd.Flags().StringVar(&loginToken, "token", "", "bearer token (for --type token)") - loginCmd.Flags().StringVar(&loginUser, "user", "", "username (for --type basic)") - loginCmd.Flags().StringVar(&loginPass, "pass", "", "password (for --type basic)") - loginCmd.Flags().StringVar(&loginKey, "key", "", "header key (for --type header)") - loginCmd.Flags().StringVar(&loginValue, "value", "", "header value (for --type header)") - rootCmd.AddCommand(loginCmd) + return cmd } func runLogin(cmd *cobra.Command, args []string) error { @@ -60,12 +64,7 @@ func runLogin(cmd *cobra.Command, args []string) error { return err } - epName, err := resolveEndpointName(args) - if err != nil { - return err - } - - ep, err := cfg.GetEndpoint(epName) + ep, err := cfg.GetEndpoint(args[0]) if err != nil { return err } diff --git a/cmd/logout.go b/cmd/logout.go index 2c47e5e..1c4344c 100644 --- a/cmd/logout.go +++ b/cmd/logout.go @@ -10,20 +10,21 @@ import ( "github.com/looplj/graphql-cli/internal/config" ) -var logoutCmd = &cobra.Command{ - Use: "logout [endpoint]", - Short: "Remove stored credentials for an endpoint", - Long: `Remove stored credentials for a GraphQL endpoint. - -Examples: - graphql-cli logout # logout from default endpoint - graphql-cli logout production # logout from specific endpoint`, - Args: cobra.MaximumNArgs(1), - RunE: runLogout, +func init() { + endpointCmd.AddCommand(newLogoutCmd()) } -func init() { - rootCmd.AddCommand(logoutCmd) +func newLogoutCmd() *cobra.Command { + return &cobra.Command{ + Use: "logout ", + Short: "Remove stored credentials for an endpoint", + Long: `Remove stored credentials for a GraphQL endpoint. + +Examples: + graphql-cli endpoint logout production`, + Args: cobra.ExactArgs(1), + RunE: runLogout, + } } func runLogout(cmd *cobra.Command, args []string) error { @@ -32,12 +33,7 @@ func runLogout(cmd *cobra.Command, args []string) error { return err } - epName, err := resolveEndpointName(args) - if err != nil { - return err - } - - ep, err := cfg.GetEndpoint(epName) + ep, err := cfg.GetEndpoint(args[0]) if err != nil { return err } diff --git a/cmd/mutate.go b/cmd/mutate.go index e3a446c..695e3cf 100644 --- a/cmd/mutate.go +++ b/cmd/mutate.go @@ -29,6 +29,7 @@ var ( ) func init() { + addEndpointFlag(mutateCmd) mutateCmd.Flags().StringVarP(&mutateFile, "file", "f", "", "read mutation from file") mutateCmd.Flags().StringVarP(&mutateVariables, "variables", "v", "", "mutation variables as JSON string") mutateCmd.Flags().StringSliceVarP(&mutateHeaders, "header", "H", nil, "extra HTTP headers (key=value), can be specified multiple times") diff --git a/cmd/query.go b/cmd/query.go index 6e894f4..fb026ed 100644 --- a/cmd/query.go +++ b/cmd/query.go @@ -34,6 +34,7 @@ var ( ) func init() { + addEndpointFlag(queryCmd) queryCmd.Flags().StringVarP(&queryFile, "file", "f", "", "read query from file") queryCmd.Flags().StringVarP(&queryVariables, "variables", "v", "", "query variables as JSON string") queryCmd.Flags().StringSliceVarP(&queryHeaders, "header", "H", nil, "extra HTTP headers (key=value), can be specified multiple times") diff --git a/cmd/root.go b/cmd/root.go index 8e9c01c..0c08aba 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -17,6 +17,9 @@ var rootCmd = &cobra.Command{ Short: "A CLI tool for exploring and querying GraphQL APIs", Long: `graphql-cli supports configuring multiple GraphQL endpoints (remote URL or local schema file), and provides subcommands to explore schemas and execute queries/mutations.`, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, } func Execute() { @@ -28,32 +31,17 @@ func Execute() { func init() { rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default: ~/.config/graphql-cli/config.yaml)") - rootCmd.PersistentFlags().StringVarP(&endpointName, "endpoint", "e", "", "endpoint name to use") +} + +func addEndpointFlag(cmd *cobra.Command) { + cmd.Flags().StringVarP(&endpointName, "endpoint", "e", "", "endpoint name to use") } // requireEndpoint is a PreRunE that ensures -e is specified. var requireEndpoint = func(cmd *cobra.Command, args []string) error { if endpointName == "" { - return fmt.Errorf("endpoint is required, use -e to specify one (see 'graphql-cli list' for available endpoints)") + return fmt.Errorf("endpoint is required, use -e to specify one (see 'graphql-cli endpoint list' for available endpoints)") } return nil } - -// resolveEndpointName returns the endpoint name from positional arg or -e flag. -func resolveEndpointName(args []string) (string, error) { - name := "" - if len(args) > 0 { - name = args[0] - } - - if name == "" { - name = endpointName - } - - if name == "" { - return "", fmt.Errorf("endpoint is required, specify as argument or use -e (see 'graphql-cli list' for available endpoints)") - } - - return name, nil -} diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 0000000..2fc6db8 --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,31 @@ +package cmd + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestEndpointFlagScope(t *testing.T) { + if rootCmd.PersistentFlags().Lookup("endpoint") != nil { + t.Fatal("expected endpoint flag to be scoped to execution commands, not root") + } + + for _, tc := range []struct { + name string + cmd *cobra.Command + }{ + {name: "query", cmd: queryCmd}, + {name: "mutate", cmd: mutateCmd}, + {name: "find", cmd: findCmd}, + } { + flag := tc.cmd.Flags().Lookup("endpoint") + if flag == nil { + t.Fatalf("expected %s command to define --endpoint", tc.name) + } + + if flag.Shorthand != "e" { + t.Fatalf("expected %s command to keep -e shorthand, got %q", tc.name, flag.Shorthand) + } + } +} diff --git a/cmd/update.go b/cmd/update.go index 2dfa09a..6512a4f 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -9,19 +9,6 @@ import ( "github.com/looplj/graphql-cli/internal/config" ) -var updateCmd = &cobra.Command{ - Use: "update ", - Short: "Update an existing endpoint's URL or headers", - Long: `Update an existing GraphQL endpoint configuration. - -Examples: - graphql-cli update production --url https://api.example.com/v2/graphql - graphql-cli update production --header "Authorization=Bearer new-token" - graphql-cli update production --url https://new-url.com/graphql --header "X-Custom=value"`, - Args: cobra.ExactArgs(1), - RunE: runUpdate, -} - var ( updateURL string updateDescription string @@ -29,10 +16,28 @@ var ( ) func init() { - updateCmd.Flags().StringVar(&updateURL, "url", "", "new GraphQL endpoint URL") - updateCmd.Flags().StringVarP(&updateDescription, "description", "d", "", "new endpoint description") - updateCmd.Flags().StringSliceVar(&updateHeaders, "header", nil, "HTTP headers to add/update (key=value), can be specified multiple times") - rootCmd.AddCommand(updateCmd) + endpointCmd.AddCommand(newUpdateCmd()) +} + +func newUpdateCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "update ", + Short: "Update an existing endpoint's URL or headers", + Long: `Update an existing GraphQL endpoint configuration. + +Examples: + graphql-cli endpoint update production --url https://api.example.com/v2/graphql + graphql-cli endpoint update production --header "Authorization=Bearer new-token" + graphql-cli endpoint update production --url https://new-url.com/graphql --header "X-Custom=value"`, + Args: cobra.ExactArgs(1), + RunE: runUpdate, + } + + cmd.Flags().StringVar(&updateURL, "url", "", "new GraphQL endpoint URL") + cmd.Flags().StringVarP(&updateDescription, "description", "d", "", "new endpoint description") + cmd.Flags().StringSliceVar(&updateHeaders, "header", nil, "HTTP headers to add/update (key=value), can be specified multiple times") + + return cmd } func runUpdate(cmd *cobra.Command, args []string) error { diff --git a/internal/audit/logger.go b/internal/audit/logger.go new file mode 100644 index 0000000..fe7058b --- /dev/null +++ b/internal/audit/logger.go @@ -0,0 +1,61 @@ +package audit + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/looplj/graphql-cli/internal/config" +) + +type Entry struct { + Timestamp string `json:"timestamp"` + Endpoint string `json:"endpoint"` + URL string `json:"url,omitempty"` + Status string `json:"status"` + Statement string `json:"statement"` + Error string `json:"error,omitempty"` +} + +func LogExecution(ep *config.Endpoint, statement string, execErr error) error { + if ep == nil { + return fmt.Errorf("nil endpoint") + } + + entry := Entry{ + Timestamp: time.Now().UTC().Format(time.RFC3339Nano), + Endpoint: ep.Name, + URL: ep.URL, + Status: "success", + Statement: statement, + } + + if execErr != nil { + entry.Status = "error" + entry.Error = execErr.Error() + } + + line, err := json.Marshal(entry) + if err != nil { + return fmt.Errorf("marshal audit entry: %w", err) + } + + path := config.DefaultAuditLogPath() + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("create audit log dir: %w", err) + } + + file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return fmt.Errorf("open audit log: %w", err) + } + defer file.Close() + + if _, err := file.Write(append(line, '\n')); err != nil { + return fmt.Errorf("write audit log: %w", err) + } + + return nil +} diff --git a/internal/audit/reader.go b/internal/audit/reader.go new file mode 100644 index 0000000..d334b99 --- /dev/null +++ b/internal/audit/reader.go @@ -0,0 +1,80 @@ +package audit + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/looplj/graphql-cli/internal/config" +) + +func ReadEntries() ([]Entry, error) { + file, err := os.Open(config.DefaultAuditLogPath()) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + + return nil, fmt.Errorf("open audit log: %w", err) + } + defer file.Close() + + entries := make([]Entry, 0) + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + lineNumber := 0 + for scanner.Scan() { + lineNumber++ + + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + var entry Entry + if err := json.Unmarshal([]byte(line), &entry); err != nil { + return nil, fmt.Errorf("parse audit log line %d: %w", lineNumber, err) + } + + entries = append(entries, entry) + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("read audit log: %w", err) + } + + return entries, nil +} + +func StatementKind(statement string) string { + statement = trimGraphQLStatement(statement) + lower := strings.ToLower(statement) + + switch { + case lower == "": + return "operation" + case strings.HasPrefix(lower, "mutation"): + return "mutation" + case strings.HasPrefix(lower, "query"), strings.HasPrefix(lower, "{"): + return "query" + default: + return "operation" + } +} + +func trimGraphQLStatement(statement string) string { + statement = strings.TrimSpace(statement) + for strings.HasPrefix(statement, "#") { + newline := strings.IndexByte(statement, '\n') + if newline == -1 { + return "" + } + + statement = strings.TrimSpace(statement[newline+1:]) + } + + return statement +} diff --git a/internal/audit/reader_test.go b/internal/audit/reader_test.go new file mode 100644 index 0000000..1af05d7 --- /dev/null +++ b/internal/audit/reader_test.go @@ -0,0 +1,72 @@ +package audit + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/looplj/graphql-cli/internal/config" +) + +func TestReadEntriesParsesAuditLog(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + path := config.DefaultAuditLogPath() + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatalf("create audit dir: %v", err) + } + + entries := []Entry{ + { + Timestamp: "2026-03-30T01:02:03Z", + Endpoint: "production", + Status: "success", + Statement: "{ viewer { id } }", + }, + { + Timestamp: "2026-03-30T01:03:04Z", + Endpoint: "production", + Status: "error", + Statement: "# create a user\nmutation CreateUser { createUser(name: \"Amp\") { id } }", + Error: "HTTP 401", + }, + } + + lines := make([]string, 0, len(entries)) + for _, entry := range entries { + data, err := json.Marshal(entry) + if err != nil { + t.Fatalf("marshal entry: %v", err) + } + + lines = append(lines, string(data)) + } + + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil { + t.Fatalf("write audit log: %v", err) + } + + parsed, err := ReadEntries() + if err != nil { + t.Fatalf("ReadEntries returned error: %v", err) + } + + if len(parsed) != 2 { + t.Fatalf("expected 2 entries, got %d", len(parsed)) + } + + if got := StatementKind(parsed[0].Statement); got != "query" { + t.Fatalf("expected first statement kind to be query, got %q", got) + } + + if got := StatementKind(parsed[1].Statement); got != "mutation" { + t.Fatalf("expected second statement kind to be mutation, got %q", got) + } + + if parsed[1].Error != "HTTP 401" { + t.Fatalf("expected error to round-trip, got %q", parsed[1].Error) + } +} diff --git a/internal/client/client.go b/internal/client/client.go index f655d96..f721caa 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -7,7 +7,9 @@ import ( "fmt" "io" "net/http" + "os" + "github.com/looplj/graphql-cli/internal/audit" "github.com/looplj/graphql-cli/internal/auth" "github.com/looplj/graphql-cli/internal/config" "github.com/looplj/graphql-cli/internal/printer" @@ -25,11 +27,17 @@ type Response struct { // Execute sends a GraphQL request. // Header priority: config headers < stored credential < extraHeaders (CLI -H flags). -func Execute(ep *config.Endpoint, query string, variables map[string]any, extraHeaders map[string]string) (*Response, error) { +func Execute(ep *config.Endpoint, query string, variables map[string]any, extraHeaders map[string]string) (result *Response, err error) { if ep.URL == "" { return nil, fmt.Errorf("endpoint %q has no URL configured; only local schema_file is available (use 'find' to explore the schema)", ep.Name) } + defer func() { + if auditErr := audit.LogExecution(ep, query, err); auditErr != nil { + fmt.Fprintf(os.Stderr, "warning: failed to write audit log: %v\n", auditErr) + } + }() + reqBody, err := json.Marshal(Request{ Query: query, Variables: variables, @@ -78,10 +86,12 @@ func Execute(ep *config.Endpoint, query string, variables map[string]any, extraH return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) } - var result Response - if err := json.Unmarshal(body, &result); err != nil { + var parsed Response + if err := json.Unmarshal(body, &parsed); err != nil { return nil, fmt.Errorf("parse response: %w", err) } - return &result, nil + result = &parsed + + return result, nil } diff --git a/internal/client/client_test.go b/internal/client/client_test.go new file mode 100644 index 0000000..b0a05c8 --- /dev/null +++ b/internal/client/client_test.go @@ -0,0 +1,63 @@ +package client + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/looplj/graphql-cli/internal/audit" + "github.com/looplj/graphql-cli/internal/config" +) + +func TestExecuteWritesAuditLog(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"ok":true}}`)) + })) + defer server.Close() + + ep := &config.Endpoint{Name: "production", URL: server.URL} + statement := "query Viewer { viewer { id } }" + + resp, err := Execute(ep, statement, nil, nil) + if err != nil { + t.Fatalf("Execute returned error: %v", err) + } + + if string(resp.Data) != `{"ok":true}` { + t.Fatalf("unexpected response data: %s", resp.Data) + } + + data, err := os.ReadFile(config.DefaultAuditLogPath()) + if err != nil { + t.Fatalf("read audit log: %v", err) + } + + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) != 1 { + t.Fatalf("expected 1 audit log line, got %d", len(lines)) + } + + var entry audit.Entry + if err := json.Unmarshal([]byte(lines[0]), &entry); err != nil { + t.Fatalf("parse audit log entry: %v", err) + } + + if entry.Endpoint != ep.Name { + t.Fatalf("expected endpoint %q, got %q", ep.Name, entry.Endpoint) + } + + if entry.Status != "success" { + t.Fatalf("expected success status, got %q", entry.Status) + } + + if entry.Statement != statement { + t.Fatalf("expected statement %q, got %q", statement, entry.Statement) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 79ffd84..e41ba00 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,9 +20,17 @@ type Config struct { Endpoints []Endpoint `yaml:"endpoints"` } -func DefaultConfigPath() string { +func DefaultConfigDir() string { home, _ := os.UserHomeDir() - return filepath.Join(home, ".config", "graphql-cli", "config.yaml") + return filepath.Join(home, ".config", "graphql-cli") +} + +func DefaultConfigPath() string { + return filepath.Join(DefaultConfigDir(), "config.yaml") +} + +func DefaultAuditLogPath() string { + return filepath.Join(DefaultConfigDir(), "audit.log") } func Load(path string) (*Config, error) { @@ -73,7 +81,7 @@ func (c *Config) Save(path string) error { func (c *Config) GetEndpoint(name string) (*Endpoint, error) { if name == "" { - return nil, fmt.Errorf("no endpoint specified, use -e to specify one (see 'graphql-cli list' for available endpoints)") + return nil, fmt.Errorf("no endpoint specified, use -e to specify one (see 'graphql-cli endpoint list' for available endpoints)") } for i := range c.Endpoints {