diff --git a/README.md b/README.md
index b004df7..6057caf 100644
--- a/README.md
+++ b/README.md
@@ -19,8 +19,6 @@ CLI for [QuickBooks Online](https://developer.intuit.com/app/developer/qbo/docs/
- **Financial reports** — profit & loss, balance sheet, and more with date range filters
- **Single binary** — install via Homebrew, Scoop, or `go install`
-

-
```bash
$ qbo list invoices --where "Balance > '0'" --sandbox --json
[
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..5fb353a
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,29 @@
+# Security Policy
+
+## Supported versions
+
+Only the latest released version of `qbo` receives security fixes. Please
+upgrade (`brew upgrade qbo`, `scoop update qbo`, or `go install` the latest tag)
+before reporting.
+
+## Reporting a vulnerability
+
+Please report security issues **privately** — do not open a public issue.
+
+- Preferred: use GitHub's [private vulnerability reporting](https://github.com/voska/qbo-cli/security/advisories/new)
+ (the "Report a vulnerability" button on the repository's **Security** tab).
+
+We aim to acknowledge reports within 5 business days and to ship a fix or
+mitigation for confirmed issues as quickly as is practical, crediting reporters
+who wish to be named.
+
+## Handling credentials
+
+`qbo` touches financial data, so a few notes on how it treats secrets:
+
+- OAuth access/refresh tokens are stored in the OS keyring (or an encrypted
+ file fallback), never in plaintext config.
+- `QBO_CLIENT_ID` / `QBO_CLIENT_SECRET` are read from the environment. Never
+ commit them, and prefer a secrets manager over shell history.
+- Data is written to stdout and hints/progress to stderr; avoid piping verbose
+ output into shared logs that might capture tokens or company data.
diff --git a/cmd/qbo/main.go b/cmd/qbo/main.go
index 40f6343..860795f 100644
--- a/cmd/qbo/main.go
+++ b/cmd/qbo/main.go
@@ -1,8 +1,11 @@
package main
import (
+ "context"
"errors"
"os"
+ "os/signal"
+ "syscall"
"github.com/alecthomas/kong"
"github.com/voska/qbo-cli/internal/cmd"
@@ -20,19 +23,21 @@ func main() {
kong.UsageOnError(),
)
- ctx, err := parser.Parse(os.Args[1:])
+ kctx, err := parser.Parse(os.Args[1:])
if err != nil {
parser.FatalIfErrorf(err)
}
- globals, err := cmd.NewGlobals(&cli)
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+
+ globals, err := cmd.NewGlobals(ctx, &cli)
if err != nil {
handleError(err)
}
globals.Version = version
- err = ctx.Run(globals)
- if err != nil {
+ if err := kctx.Run(globals); err != nil {
handleError(err)
}
}
diff --git a/internal/api/batch.go b/internal/api/batch.go
index 702f78f..227a6e5 100644
--- a/internal/api/batch.go
+++ b/internal/api/batch.go
@@ -7,15 +7,11 @@ import (
"github.com/voska/qbo-cli/internal/errfmt"
)
-type BatchItem struct {
- BId string `json:"bId"`
- Operation string `json:"operation,omitempty"`
- Query string `json:"Query,omitempty"`
- Entity string `json:"-"`
- Body any `json:",omitempty"`
-}
-
-func (c *Client) Batch(ctx context.Context, items []BatchItem) (map[string]any, error) {
+// Batch posts a QBO batch request. items are the raw BatchItemRequest entries
+// (each in QBO's native batch format, e.g. {"bId":"1","operation":"create","Invoice":{...}}
+// or {"bId":"2","Query":"select * from Invoice"}), passed through verbatim
+// under the BatchItemRequest envelope.
+func (c *Client) Batch(ctx context.Context, items []json.RawMessage) (map[string]any, error) {
payload := map[string]any{
"BatchItemRequest": items,
}
diff --git a/internal/api/batch_test.go b/internal/api/batch_test.go
new file mode 100644
index 0000000..381d854
--- /dev/null
+++ b/internal/api/batch_test.go
@@ -0,0 +1,47 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+// Batch must pass the parsed items through under BatchItemRequest (regression
+// test for the bug where the command discarded its input and posted nil).
+func TestBatchPostsItems(t *testing.T) {
+ var gotBody map[string]any
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/v3/company/123/batch" {
+ t.Errorf("path = %q", r.URL.Path)
+ }
+ b, _ := io.ReadAll(r.Body)
+ _ = json.Unmarshal(b, &gotBody)
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"BatchItemResponse":[{"bId":"1"}]}`))
+ }))
+ defer srv.Close()
+
+ items := []json.RawMessage{
+ json.RawMessage(`{"bId":"1","operation":"create","Invoice":{"x":1}}`),
+ json.RawMessage(`{"bId":"2","Query":"select * from Invoice"}`),
+ }
+ resp, err := testClient(srv).Batch(context.Background(), items)
+ if err != nil {
+ t.Fatalf("Batch: %v", err)
+ }
+
+ arr, ok := gotBody["BatchItemRequest"].([]any)
+ if !ok || len(arr) != 2 {
+ t.Fatalf("BatchItemRequest not passed through: %v", gotBody)
+ }
+ first, _ := arr[0].(map[string]any)
+ if first["bId"] != "1" {
+ t.Errorf("first item not preserved: %v", first)
+ }
+ if _, ok := resp["BatchItemResponse"]; !ok {
+ t.Errorf("response not returned: %v", resp)
+ }
+}
diff --git a/internal/api/client.go b/internal/api/client.go
index f71fd1f..87fe335 100644
--- a/internal/api/client.go
+++ b/internal/api/client.go
@@ -8,6 +8,7 @@ import (
"net/http"
"net/url"
"strings"
+ "time"
"github.com/voska/qbo-cli/internal/errfmt"
"golang.org/x/oauth2"
@@ -16,6 +17,11 @@ import (
const (
BaseURLProduction = "https://quickbooks.api.intuit.com"
BaseURLSandbox = "https://sandbox-quickbooks.api.intuit.com"
+
+ // requestTimeout bounds a single API call so a stalled connection can't
+ // hang the CLI indefinitely. Per-call cancellation also flows from the
+ // request context (e.g. Ctrl-C), this is the hard upper bound.
+ requestTimeout = 60 * time.Second
)
type Client struct {
@@ -30,8 +36,10 @@ func NewClient(token *oauth2.Token, realmID string, sandbox bool, minorVersion i
if sandbox {
base = BaseURLSandbox
}
+ httpClient := oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(token))
+ httpClient.Timeout = requestTimeout
return &Client{
- httpClient: oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(token)),
+ httpClient: httpClient,
baseURL: base,
realmID: realmID,
minorVersion: minorVersion,
diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go
index 8bae70e..c22343f 100644
--- a/internal/auth/oauth.go
+++ b/internal/auth/oauth.go
@@ -13,20 +13,27 @@ import (
"os/exec"
"runtime"
"strings"
+ "time"
"github.com/voska/qbo-cli/internal/errfmt"
"golang.org/x/oauth2"
)
+// loginTimeout bounds the local browser-callback login so an abandoned login
+// releases the callback port instead of hanging forever.
+const loginTimeout = 5 * time.Minute
+
type AuthResult struct {
Token *oauth2.Token
RealmID string
}
-func GenerateState() string {
+func GenerateState() (string, error) {
b := make([]byte, 16)
- _, _ = rand.Read(b)
- return hex.EncodeToString(b)
+ if _, err := rand.Read(b); err != nil {
+ return "", errfmt.Wrap(errfmt.ExitError, "cannot generate secure OAuth state", err)
+ }
+ return hex.EncodeToString(b), nil
}
func GetAuthURL(clientID, clientSecret, redirectURL, state string) string {
@@ -80,6 +87,9 @@ func LoginInteractive(ctx context.Context, clientID, clientSecret, redirectURL s
}
func loginLocal(ctx context.Context, clientID, clientSecret, redirectURL string) (*AuthResult, error) {
+ ctx, cancel := context.WithTimeout(ctx, loginTimeout)
+ defer cancel()
+
addr := fmt.Sprintf("127.0.0.1:%d", DefaultCallbackPort)
listener, err := net.Listen("tcp", addr)
if err != nil {
@@ -87,7 +97,10 @@ func loginLocal(ctx context.Context, clientID, clientSecret, redirectURL string)
}
cfg := OAuthConfig(clientID, clientSecret, redirectURL)
- state := GenerateState()
+ state, err := GenerateState()
+ if err != nil {
+ return nil, err
+ }
resultCh := make(chan *AuthResult, 1)
errCh := make(chan error, 1)
@@ -140,7 +153,10 @@ func loginLocal(ctx context.Context, clientID, clientSecret, redirectURL string)
func loginManual(ctx context.Context, clientID, clientSecret, redirectURL string) (*AuthResult, error) {
cfg := OAuthConfig(clientID, clientSecret, redirectURL)
- state := GenerateState()
+ state, err := GenerateState()
+ if err != nil {
+ return nil, err
+ }
authURL := cfg.AuthCodeURL(state, oauth2.AccessTypeOffline)
fmt.Fprintf(os.Stderr, "Open this URL in your browser:\n\n %s\n\n", authURL)
diff --git a/internal/auth/token.go b/internal/auth/token.go
index a342d0e..7dd46a0 100644
--- a/internal/auth/token.go
+++ b/internal/auth/token.go
@@ -13,6 +13,11 @@ import (
const keyringService = "qbo-cli"
+// expiryLeeway treats a token as expired slightly before its real expiry so a
+// request doesn't go out with a token that lapses mid-flight (mirrors the
+// oauth2 library's default expiry delta).
+const expiryLeeway = 30 * time.Second
+
func tokenDir() string {
if d := os.Getenv("QBO_CONFIG_DIR"); d != "" {
return filepath.Join(d, "tokens")
@@ -90,5 +95,5 @@ func ListTokenKeys() ([]string, error) {
}
func IsTokenExpired(token *oauth2.Token) bool {
- return token.Expiry.Before(time.Now())
+ return token.Expiry.Add(-expiryLeeway).Before(time.Now())
}
diff --git a/internal/cmd/batch.go b/internal/cmd/batch.go
index 6dc2362..9ea19b1 100644
--- a/internal/cmd/batch.go
+++ b/internal/cmd/batch.go
@@ -17,8 +17,16 @@ func (c *BatchCmd) Run(g *Globals) error {
return err
}
+ var items []json.RawMessage
+ if err := json.Unmarshal(body, &items); err != nil {
+ return errfmt.Wrap(errfmt.ExitUsage, "invalid batch JSON (expected a JSON array of batch items)", err)
+ }
+ if len(items) == 0 {
+ return errfmt.Usage("batch file contains no items")
+ }
+
if g.CLI.DryRun {
- output.Hint("[dry-run] POST /v3/company/{id}/batch")
+ output.Hint("[dry-run] POST /v3/company/{id}/batch (%d items)", len(items))
DryRunLog(g.CLI, "POST", "batch", body)
return nil
}
@@ -28,12 +36,7 @@ func (c *BatchCmd) Run(g *Globals) error {
return err
}
- var items []map[string]any
- if err := json.Unmarshal(body, &items); err != nil {
- return errfmt.Wrap(errfmt.ExitUsage, "invalid batch JSON", err)
- }
-
- result, err := client.Batch(g.Ctx, nil)
+ result, err := client.Batch(g.Ctx, items)
if err != nil {
return err
}
diff --git a/internal/cmd/cmd_test.go b/internal/cmd/cmd_test.go
new file mode 100644
index 0000000..df1f3c7
--- /dev/null
+++ b/internal/cmd/cmd_test.go
@@ -0,0 +1,73 @@
+package cmd
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/voska/qbo-cli/internal/errfmt"
+)
+
+func exitCode(err error) int {
+ var e *errfmt.Error
+ if errors.As(err, &e) {
+ return e.Code
+ }
+ return -1
+}
+
+func testGlobals(cli *CLI) *Globals {
+ return &Globals{Ctx: context.Background(), CLI: cli}
+}
+
+func TestDeleteUnknownEntity(t *testing.T) {
+ err := (&DeleteCmd{Entity: "bogus", ID: "1"}).Run(testGlobals(&CLI{}))
+ if got := exitCode(err); got != errfmt.ExitUsage {
+ t.Fatalf("exit = %d, want %d (%v)", got, errfmt.ExitUsage, err)
+ }
+}
+
+// --no-input must refuse (fail), not silently delete — only --force authorizes it.
+func TestDeleteNoInputWithoutForceFails(t *testing.T) {
+ err := (&DeleteCmd{Entity: "invoice", ID: "5"}).Run(testGlobals(&CLI{NoInput: true}))
+ if got := exitCode(err); got != errfmt.ExitUsage {
+ t.Fatalf("--no-input delete should fail with ExitUsage, got exit %d (%v)", got, err)
+ }
+}
+
+// Dry-run must return before constructing the API client / making any call.
+func TestDeleteDryRunNoNetwork(t *testing.T) {
+ if err := (&DeleteCmd{Entity: "invoice", ID: "5"}).Run(testGlobals(&CLI{DryRun: true})); err != nil {
+ t.Fatalf("dry-run delete returned error: %v", err)
+ }
+}
+
+func TestCreateUnknownEntity(t *testing.T) {
+ err := (&CreateCmd{Entity: "bogus"}).Run(testGlobals(&CLI{}))
+ if got := exitCode(err); got != errfmt.ExitUsage {
+ t.Fatalf("exit = %d, want %d (%v)", got, errfmt.ExitUsage, err)
+ }
+}
+
+func TestBatchRejectsEmptyAndInvalid(t *testing.T) {
+ dir := t.TempDir()
+ write := func(name, content string) string {
+ p := filepath.Join(dir, name)
+ if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ return p
+ }
+
+ empty := write("empty.json", "[]")
+ if got := exitCode((&BatchCmd{File: empty}).Run(testGlobals(&CLI{}))); got != errfmt.ExitUsage {
+ t.Errorf("empty batch: exit = %d, want %d", got, errfmt.ExitUsage)
+ }
+
+ bad := write("bad.json", "{not json")
+ if got := exitCode((&BatchCmd{File: bad}).Run(testGlobals(&CLI{}))); got != errfmt.ExitUsage {
+ t.Errorf("invalid batch: exit = %d, want %d", got, errfmt.ExitUsage)
+ }
+}
diff --git a/internal/cmd/delete.go b/internal/cmd/delete.go
index 89edd9e..9744d21 100644
--- a/internal/cmd/delete.go
+++ b/internal/cmd/delete.go
@@ -33,7 +33,10 @@ func (c *DeleteCmd) Run(g *Globals) error {
return nil
}
- if !g.CLI.Force && !g.CLI.NoInput {
+ if !g.CLI.Force {
+ if g.CLI.NoInput {
+ return errfmt.Usage("refusing to delete without --force in --no-input mode")
+ }
fmt.Fprintf(os.Stderr, "Delete %s %s? [y/N] ", entity.Name, c.ID)
var confirm string
_, _ = fmt.Scanln(&confirm)
diff --git a/internal/cmd/list.go b/internal/cmd/list.go
index b1d98c1..a1f1c5b 100644
--- a/internal/cmd/list.go
+++ b/internal/cmd/list.go
@@ -44,5 +44,11 @@ func (c *ListCmd) Run(g *Globals) error {
if err != nil {
return err
}
- return WriteOutput(g.Ctx, result)
+ if err := WriteOutput(g.Ctx, result); err != nil {
+ return err
+ }
+ if output.IsEmpty(result) {
+ return errfmt.Empty()
+ }
+ return nil
}
diff --git a/internal/cmd/query.go b/internal/cmd/query.go
index fbb3953..062b191 100644
--- a/internal/cmd/query.go
+++ b/internal/cmd/query.go
@@ -27,5 +27,11 @@ func (c *QueryCmd) Run(g *Globals) error {
if err != nil {
return err
}
- return WriteOutput(g.Ctx, result)
+ if err := WriteOutput(g.Ctx, result); err != nil {
+ return err
+ }
+ if output.IsEmpty(result) {
+ return errfmt.Empty()
+ }
+ return nil
}
diff --git a/internal/cmd/root.go b/internal/cmd/root.go
index 2edc5e4..44fc12c 100644
--- a/internal/cmd/root.go
+++ b/internal/cmd/root.go
@@ -49,14 +49,14 @@ type Globals struct {
Version string
}
-func NewGlobals(cli *CLI) (*Globals, error) {
+func NewGlobals(ctx context.Context, cli *CLI) (*Globals, error) {
cfg, err := config.Load()
if err != nil {
return nil, err
}
opts := output.NewOptions(cli.JSON, cli.Plain, cli.ResultsOnly, cli.Select)
return &Globals{
- Ctx: output.WithOptions(context.Background(), opts),
+ Ctx: output.WithOptions(ctx, opts),
Config: cfg,
OutOpts: opts,
CLI: cli,
diff --git a/internal/cmd/schema.go b/internal/cmd/schema.go
index 0ce7520..9b55668 100644
--- a/internal/cmd/schema.go
+++ b/internal/cmd/schema.go
@@ -47,7 +47,7 @@ func fullSchema(version string) map[string]any {
"name": "auth",
"help": "Authentication (login, logout, status, refresh)",
"subcommands": []map[string]any{
- {"name": "login", "help": "Authenticate with QuickBooks Online", "flags": []string{"--manual"}},
+ {"name": "login", "help": "Authenticate with QuickBooks Online", "flags": []string{"--manual", "--redirect-uri"}},
{"name": "logout", "help": "Remove stored credentials"},
{"name": "status", "help": "Show current auth status"},
{"name": "refresh", "help": "Force token refresh"},
diff --git a/internal/output/select.go b/internal/output/select.go
index c943b6a..16bdb0c 100644
--- a/internal/output/select.go
+++ b/internal/output/select.go
@@ -52,6 +52,15 @@ func getPath(m map[string]any, path string) any {
return current
}
+// IsEmpty reports whether a QBO query/list response contains zero rows.
+// Non-query responses (single reads, reports) are never considered empty.
+func IsEmpty(data any) bool {
+ if arr, ok := StripMetadata(data).([]any); ok {
+ return len(arr) == 0
+ }
+ return false
+}
+
func StripMetadata(data any) any {
m, ok := data.(map[string]any)
if !ok {
diff --git a/internal/output/select_test.go b/internal/output/select_test.go
index ee31268..18cc9d4 100644
--- a/internal/output/select_test.go
+++ b/internal/output/select_test.go
@@ -73,6 +73,27 @@ func TestStripMetadata(t *testing.T) {
}
}
+func TestIsEmpty(t *testing.T) {
+ cases := []struct {
+ name string
+ raw string
+ want bool
+ }{
+ {"populated query", `{"QueryResponse":{"Invoice":[{"Id":"1"}],"maxResults":1}}`, false},
+ {"empty query (key omitted)", `{"QueryResponse":{"maxResults":0,"startPosition":1}}`, true},
+ {"single read is not empty", `{"Invoice":{"Id":"1"},"time":"t"}`, false},
+ }
+ for _, tt := range cases {
+ t.Run(tt.name, func(t *testing.T) {
+ var data any
+ _ = json.Unmarshal([]byte(tt.raw), &data)
+ if got := IsEmpty(data); got != tt.want {
+ t.Errorf("IsEmpty = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
func TestStripMetadataRecurringTransaction(t *testing.T) {
raw := `{"QueryResponse":{"RecurringTransaction":[{"Invoice":{"Id":"1"}},{"Bill":{"Id":"2"}}],"maxResults":2},"time":"t"}`
var data any
diff --git a/site/llms.txt b/site/llms.txt
index c24cd8d..a797240 100644
--- a/site/llms.txt
+++ b/site/llms.txt
@@ -22,7 +22,7 @@ qbo is a free, open-source command-line interface for QuickBooks Online written
## Commands
- `qbo auth login|logout|status|refresh` — OAuth 2.0 authentication
-- `qbo list ` — List entities with optional filters (--where, --orderby, --limit)
+- `qbo list ` — List entities with optional filters (--where, --order-by, --limit)
- `qbo get ` — Read a single entity by ID
- `qbo create ` — Create from JSON file or stdin
- `qbo update ` — Update (full or --sparse)
diff --git a/skills/qbo/references/COMMANDS.md b/skills/qbo/references/COMMANDS.md
index c38a403..88f95c5 100644
--- a/skills/qbo/references/COMMANDS.md
+++ b/skills/qbo/references/COMMANDS.md
@@ -6,7 +6,7 @@
|------|-------|-------------|
| `--json` | `-j` | Output JSON to stdout |
| `--plain` | `-p` | Output TSV, no color |
-| `--select` | | Project output to fields (top-level keys only) |
+| `--select` | | Project output to fields (dot paths supported) |
| `--results-only` | | Strip QueryResponse wrapper, return entity array |
| `--company-id` | | Target company realm ID |
| `--sandbox` | | Use sandbox environment (sandbox-quickbooks.api.intuit.com) |
@@ -118,10 +118,20 @@ Raw QBO SQL-like query. Combine with `--results-only` for clean arrays.
### batch
+`batch.json` is a JSON array of QBO batch items (each with a `bId` and either an
+operation on an entity or a `Query`):
+
```bash
qbo batch --file batch.json
```
+```json
+[
+ { "bId": "1", "operation": "create", "Invoice": { "...": "..." } },
+ { "bId": "2", "Query": "select * from Invoice" }
+]
+```
+
### cdc
```bash