Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## Unreleased

FEATURES:

* `flaggr sourcemaps upload|list|delete`: upload a release's source maps (`--release`, `--url-prefix`, `--dry-run`), gzipped and four at a time. Flaggr's Issues page then shows minified stack frames as your original source.
* Opt-in crash reports: `flaggr crash-reports on|off|status` (or `FLAGGR_CRASH_REPORTS=1|0`). When they're on, a panic is reported to the Flaggr team with its message, stack, the CLI's version, the subcommand name and the OS, architecture and Go version. The report never includes the token, arguments or output. `DO_NOT_TRACK=1` and CI environments keep reports off. The panic still prints and exits as before. Reports use the Go SDK's new `errorreport` package.

## 0.5.0 (October 10, 2026)

First public release, from `github.com/flaggr-dev/flaggr-cli`. Earlier versions were built in Flaggr's private repository and were available only on request.
Expand Down
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ sudo install -m 0755 flaggr /usr/local/bin/flaggr

### With Go

With Go 1.26 or later:
With Go 1.27 or later:

```sh
go install github.com/flaggr-dev/flaggr-cli/cmd/flaggr@latest
Expand Down Expand Up @@ -92,6 +92,8 @@ flaggr login --token fgp_your_personal_access_token # or a project API token (
| `flaggr audit log --project <id>` | The project's audit log, with diffs |
| `flaggr audit history --key <key> --service <id>` | A flag's version history |
| `flaggr export --project <id>` | A project's configuration as JSON, to stdout or `--output <file>` |
| `flaggr sourcemaps upload <dir> --release <r> --url-prefix <path>` | Upload source maps, so the Issues page shows minified stack traces as your source (also `sourcemaps list` and `sourcemaps delete`) |
| `flaggr crash-reports [on\|off\|status]` | Send the CLI's crashes to the Flaggr team (off by default) |

Every command takes `--json` for JSON output. `flaggr <command> --help` lists a command's flags.

Expand All @@ -104,6 +106,33 @@ Every command takes `--json` for JSON output. `flaggr <command> --help` lists a
| `FLAGGR_API_URL` | `https://flaggr.dev` | The Flaggr API. For a self-hosted Flaggr, `flaggr login --url https://flaggr.example.com` saves it instead. |
| `FLAGGR_API_TOKEN` | The saved token | The token every command sends |
| `FLAGGR_EVAL_URL` | `https://api.flaggr.dev` with flaggr.dev, otherwise the API URL | Where `flaggr eval` sends evaluations |
| `FLAGGR_CRASH_REPORTS` | The saved setting (off) | `1` or `0` turns crash reports on or off |

## Source maps

Upload the source maps of each release you deploy, under the release your app reports. Flaggr then shows the minified frames on the Issues page as the original file, line and source:

```sh
# Next.js: the maps of .next/static, served under /_next/static
flaggr sourcemaps upload .next/static --url-prefix /_next/static --release "$GIT_SHA" --project <id>
```

Each map is filed under the path its minified file has in stack traces: `--url-prefix` plus the map's path in the directory, without `.map`. Run `--dry-run` to see those paths without uploading anything. Maps are gzipped before they're sent, and each must be under 64 MiB. A project keeps the maps of its last 20 releases. Your build must emit source maps; for Next.js that means `productionBrowserSourceMaps: true`. You don't have to serve them publicly.

## Crash reports

Crash reports are off unless you turn them on with `flaggr crash-reports on` or `FLAGGR_CRASH_REPORTS=1`. `DO_NOT_TRACK=1` and CI environments (`CI` set) always keep them off.

When they're on and the CLI crashes (a panic), a report goes to the Flaggr team at `https://flaggr.dev/api/ingest/cli-crashes`. The report contains:

- the panic's message and stack,
- the CLI's version,
- the subcommand that ran, such as `flags list`, never its arguments or flags,
- your OS, architecture and Go version.

It never contains your token, URLs, flag keys or command output. The endpoint takes no credential and stores reports only in Flaggr's own project. It removes tokens and email addresses from the message and stack.

Errors a command returns, such as a rejected token or a network failure, aren't crashes and are never reported. When reports are off, a crash prints how to report it instead.

## Development

Expand Down
28 changes: 28 additions & 0 deletions THIRD_PARTY_NOTICES
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,34 @@ connectrpc.com/connect
See the License for the specific language governing permissions and
limitations under the License.

================================================================================
github.com/flaggr-dev/flaggr-go
================================================================================

--- LICENSE ---

MIT License

Copyright (c) 2024-present Flaggr Contributors

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

================================================================================
github.com/inconshreveable/mousetrap
================================================================================
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
module github.com/flaggr-dev/flaggr-cli

go 1.26.0
go 1.27.0

toolchain go1.27.2

require (
connectrpc.com/connect v1.21.0
github.com/flaggr-dev/flaggr-go v0.2.0
github.com/spf13/cobra v1.10.2
google.golang.org/protobuf v1.36.12
)
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
connectrpc.com/connect v1.21.0 h1:LhqSJt7jHf5NJBo9Jq/t/9FjcYAideif0mg+qe2jCUs=
connectrpc.com/connect v1.21.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/flaggr-dev/flaggr-go v0.2.0 h1:3O7of36hAlbgnNE22izN68IuJRMaQhrpxJVkozxoSnA=
github.com/flaggr-dev/flaggr-go v0.2.0/go.mod h1:tbPxIr9BK9e9EHWGsBM3LZi/kVE2LKrzD3UPy+Fchug=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
Expand Down
127 changes: 127 additions & 0 deletions internal/api/sourcemaps.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package api

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
)

// SourceMapUpload is what the API answers for one uploaded map.
type SourceMapUpload struct {
Release string `json:"release"`
File string `json:"file"`
Bytes int `json:"bytes"`
Replaced bool `json:"replaced"`
}

// SourceMapRelease is a release with uploaded maps.
type SourceMapRelease struct {
Release string `json:"release"`
Files int `json:"files"`
Bytes int64 `json:"bytes"`
LastUploadedAt string `json:"lastUploadedAt"`
}

// SourceMapFile is one uploaded map of a release.
type SourceMapFile struct {
File string `json:"file"`
Bytes int64 `json:"bytes"`
UploadedBy string `json:"uploadedBy"`
UploadedAt string `json:"uploadedAt"`
}

func sourceMapsPath(projectID string, query url.Values) string {
path := "/api/projects/" + url.PathEscape(projectID) + "/sourcemaps"
if len(query) > 0 {
path += "?" + query.Encode()
}
return path
}

// UploadSourceMap sends one gzipped source map for the minified file `file`
// (its path in stack traces, such as /_next/static/chunks/app/page-3f9a.js).
func (c *RESTClient) UploadSourceMap(ctx context.Context, projectID, release, file string, gzipped []byte) (*SourceMapUpload, error) {
query := url.Values{"file": {file}}
if release != "" {
query.Set("release", release)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPut, c.BaseURL+sourceMapsPath(projectID, query), bytes.NewReader(gzipped))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Content-Encoding", "gzip")
if c.APIToken != "" {
req.Header.Set("Authorization", "Bearer "+c.APIToken)
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
var out SourceMapUpload
if err := json.Unmarshal(body, &out); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
return &out, nil
}

// ListSourceMapReleases lists a project's releases with maps, newest first.
func (c *RESTClient) ListSourceMapReleases(ctx context.Context, projectID string) ([]SourceMapRelease, error) {
data, err := c.do(ctx, http.MethodGet, sourceMapsPath(projectID, nil), nil)
if err != nil {
return nil, err
}
var out struct {
Releases []SourceMapRelease `json:"releases"`
}
if err := json.Unmarshal(data, &out); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
return out.Releases, nil
}

// ListSourceMapFiles lists one release's maps.
func (c *RESTClient) ListSourceMapFiles(ctx context.Context, projectID, release string) ([]SourceMapFile, error) {
data, err := c.do(ctx, http.MethodGet, sourceMapsPath(projectID, url.Values{"release": {release}}), nil)
if err != nil {
return nil, err
}
var out struct {
Files []SourceMapFile `json:"files"`
}
if err := json.Unmarshal(data, &out); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
return out.Files, nil
}

// DeleteSourceMaps deletes a release's maps, or one file's when file isn't empty.
func (c *RESTClient) DeleteSourceMaps(ctx context.Context, projectID, release, file string) (int, error) {
query := url.Values{"release": {release}}
if file != "" {
query.Set("file", file)
}
data, err := c.do(ctx, http.MethodDelete, sourceMapsPath(projectID, query), nil)
if err != nil {
return 0, err
}
var out struct {
Deleted int `json:"deleted"`
}
if err := json.Unmarshal(data, &out); err != nil {
return 0, fmt.Errorf("decode: %w", err)
}
return out.Deleted, nil
}
59 changes: 59 additions & 0 deletions internal/cmd/crash_reports.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package cmd

import (
"fmt"
"os"

"github.com/flaggr-dev/flaggr-cli/internal/config"
"github.com/flaggr-dev/flaggr-cli/internal/crash"
"github.com/flaggr-dev/flaggr-cli/internal/output"
"github.com/spf13/cobra"
)

func newCrashReportsCmd() *cobra.Command {
return &cobra.Command{
Use: "crash-reports [on|off|status]",
Short: "Send the CLI's crashes to the Flaggr team (off by default)",
ValidArgs: []string{"on", "off", "status"},
Args: cobra.MatchAll(cobra.MaximumNArgs(1), cobra.OnlyValidArgs),
Long: `Turn crash reports on or off, or show whether they are on.

When the CLI crashes (a panic, not an error such as a bad token or a network
failure), a report goes to the Flaggr team: the panic's message and stack,
the CLI's version, the subcommand that ran (never its arguments or flags),
and your OS, architecture and Go version. Never your token, URLs, flag keys
or output.

Reports are off unless you turn them on. FLAGGR_CRASH_REPORTS=1 or 0
overrides this setting; DO_NOT_TRACK=1 and CI environments always turn
reports off.`,
RunE: func(cmd *cobra.Command, args []string) error {
action := "status"
if len(args) == 1 {
action = args[0]
}
c := config.Load()
if action == "on" || action == "off" {
on := action == "on"
c.CrashReports = &on
if err := config.Save(c); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
}
on, reason := crash.Enabled(c, os.Getenv)
state := "off"
if on {
state = "on"
}
if jsonOut {
output.JSON(map[string]any{"crashReports": on, "reason": reason})
return nil
}
if action != "status" {
output.Success("Crash reports " + action)
}
fmt.Printf("Crash reports are %s (%s).\n", state, reason)
return nil
},
}
}
17 changes: 17 additions & 0 deletions internal/cmd/crash_reports_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package cmd

import "testing"

func TestCommandPathNamesTheSubcommandOnly(t *testing.T) {
root := NewRootCmd()
cases := map[string][]string{
"flags list": {"flags", "list", "--project", "secret-project", "--json"},
"crash-reports": {"crash-reports", "on"},
"": {"--version"},
}
for want, args := range cases {
if got := commandPath(root, args); got != want {
t.Errorf("commandPath(%v) = %q, want %q", args, got, want)
}
}
}
20 changes: 19 additions & 1 deletion internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ package cmd

import (
"os"
"strings"

"github.com/flaggr-dev/flaggr-cli/internal/api"
"github.com/flaggr-dev/flaggr-cli/internal/config"
"github.com/flaggr-dev/flaggr-cli/internal/crash"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -55,6 +57,8 @@ func NewRootCmd() *cobra.Command {
newHealthCmd(),
newAuditCmd(),
newExportCmd(),
newCrashReportsCmd(),
newSourceMapsCmd(),
)

return root
Expand All @@ -69,7 +73,21 @@ func commitKnown(commit string) bool {
func Execute(version, commit string) {
CLIVersion = version
CLICommit = commit
if err := NewRootCmd().Execute(); err != nil {
root := NewRootCmd()
// A panic is reported to the Flaggr team when the user opted in, then
// re-panics (internal/crash).
defer crash.Guard{Version: version, Command: func() string { return commandPath(root, os.Args[1:]) }}.Recover()
if err := root.Execute(); err != nil {
os.Exit(1)
}
}

// commandPath is the subcommand args select, such as "flags list": the
// command names only, never their arguments or flags.
func commandPath(root *cobra.Command, args []string) string {
found, _, err := root.Find(args)
if err != nil || found == nil || found == root {
return ""
}
return strings.TrimPrefix(found.CommandPath(), root.Name()+" ")
}
Loading
Loading