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
140 changes: 140 additions & 0 deletions cmd/share.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package cmd

import (
"bytes"
"context"
"fmt"
"os"
"time"

"github.com/spf13/cobra"

"github.com/supermodeltools/cli/internal/api"
"github.com/supermodeltools/cli/internal/audit"
"github.com/supermodeltools/cli/internal/cache"
"github.com/supermodeltools/cli/internal/config"
)

func init() {
var dir string

c := &cobra.Command{
Use: "share",
Short: "Upload your codebase health report and get a public URL",
Long: `Runs a health audit and uploads the report to supermodeltools.com,
returning a short public URL you can share or embed as a README badge.

Example:

supermodel share
supermodel share --dir ./path/to/project`,
RunE: func(cmd *cobra.Command, _ []string) error {
return runShare(cmd, dir)
},
SilenceUsage: true,
}

c.Flags().StringVar(&dir, "dir", "", "project directory (default: current working directory)")
rootCmd.AddCommand(c)
}

func runShare(cmd *cobra.Command, dir string) error {
rootDir, projectName, err := resolveAuditDir(dir)
if err != nil {
return err
}

cfg, err := config.Load()
if err != nil {
return err
}

// Run the full audit pipeline.
ir, err := shareAnalyze(cmd, cfg, rootDir, projectName)
if err != nil {
return err
}

report := audit.Analyze(ir, projectName)

impact, err := runImpactForShare(cmd, cfg, rootDir)
if err != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "Warning: impact analysis unavailable: %v\n", err)
} else {
audit.EnrichWithImpact(report, impact)
}

// Render to Markdown.
var buf bytes.Buffer
audit.RenderHealth(&buf, report)

// Upload and get public URL.
client := api.New(cfg)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()

fmt.Fprintln(cmd.ErrOrStderr(), "Uploading report…")
url, err := client.Share(ctx, api.ShareRequest{
ProjectName: projectName,
Status: string(report.Status),
Content: buf.String(),
})
if err != nil {
return fmt.Errorf("upload failed: %w", err)
}

fmt.Fprintf(cmd.OutOrStdout(), "\n Report: %s\n\n", url)
fmt.Fprintf(cmd.OutOrStdout(), " Add this badge to your README:\n\n")
fmt.Fprintf(cmd.OutOrStdout(),
" [![Supermodel](https://img.shields.io/badge/supermodel-%s-blueviolet)](%s)\n\n",
report.Status, url)

return nil
}

func shareAnalyze(cmd *cobra.Command, cfg *config.Config, rootDir, projectName string) (*api.SupermodelIR, error) {
if err := cfg.RequireAPIKey(); err != nil {
return nil, err
}

fmt.Fprintln(cmd.ErrOrStderr(), "Creating repository archive…")
zipPath, err := audit.CreateZip(rootDir)
if err != nil {
return nil, fmt.Errorf("create archive: %w", err)
}
defer func() { _ = os.Remove(zipPath) }()

hash, err := cache.HashFile(zipPath)
if err != nil {
return nil, fmt.Errorf("hash archive: %w", err)
}

client := api.New(cfg)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()

fmt.Fprintf(cmd.ErrOrStderr(), "Analyzing %s…\n", projectName)
return client.AnalyzeDomains(ctx, zipPath, "share-"+hash[:16])
}

func runImpactForShare(cmd *cobra.Command, cfg *config.Config, rootDir string) (*api.ImpactResult, error) {
zipPath, err := audit.CreateZip(rootDir)
if err != nil {
return nil, err
}
defer func() { _ = os.Remove(zipPath) }()

hash, err := cache.HashFile(zipPath)
if err != nil {
return nil, err
}

client := api.New(cfg)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()

fmt.Fprintln(cmd.ErrOrStderr(), "Running impact analysis…")
return client.Impact(ctx, zipPath, "share-impact-"+hash[:16], "", "")
}

// resolveAuditDir and findGitRoot are defined in cmd/audit.go (same package).
16 changes: 16 additions & 0 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,22 @@ func (c *Client) DisplayGraph(ctx context.Context, repoID, idempotencyKey string
return &g, nil
}

// Share uploads a rendered report and returns a public URL.
func (c *Client) Share(ctx context.Context, req ShareRequest) (string, error) {
var resp ShareResponse
if err := c.request(ctx, "POST", "/v1/share", "application/json",
jsonBody(req), "", &resp); err != nil {
return "", err
}
return resp.URL, nil
}
Comment on lines +371 to +379
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add idempotency support for POST /v1/share to prevent duplicate public reports.

Right now Share hardcodes an empty key, so retries/reruns can create multiple links for the same report.

Suggested fix
-func (c *Client) Share(ctx context.Context, req ShareRequest) (string, error) {
+func (c *Client) Share(ctx context.Context, req ShareRequest, idempotencyKey string) (string, error) {
 	var resp ShareResponse
-	if err := c.request(ctx, "POST", "/v1/share", "application/json",
-		jsonBody(req), "", &resp); err != nil {
+	body, err := jsonBody(req)
+	if err != nil {
+		return "", fmt.Errorf("encode share payload: %w", err)
+	}
+	if err := c.request(ctx, http.MethodPost, "/v1/share", "application/json",
+		body, idempotencyKey, &resp); err != nil {
 		return "", err
 	}
 	return resp.URL, nil
 }

And in caller (cmd/share.go), pass a deterministic key (for example from rendered markdown bytes).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/api/client.go` around lines 371 - 379, The Share method always sends
an empty idempotency key causing duplicate public links; update Client.Share to
accept or compute and include a deterministic idempotency key in the POST
payload/header when calling c.request (referencing Share, ShareRequest,
ShareResponse and c.request), e.g., add an IdempotencyKey field to ShareRequest
or build a key from the rendered content and pass it through jsonBody(req) or as
a header argument to c.request; then update the caller (cmd/share.go) to compute
and provide a stable key (for example a hash of the rendered markdown bytes) so
retries use the same idempotency key.


// jsonBody encodes v as JSON and returns it as an io.Reader.
func jsonBody(v any) io.Reader {
b, _ := json.Marshal(v)
return bytes.NewReader(b)
}
Comment on lines +381 to +385
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify ignored json.Marshal errors and current jsonBody usage.
rg -n --type go 'b,\s*_\s*:=\s*json\.Marshal\(' -C2
rg -n --type go '\bjsonBody\s*\(' -C2

Repository: supermodeltools/cli

Length of output: 920


🏁 Script executed:

# Find the request method definition to understand its signature
rg -n --type go 'func\s*\(\w+\s+\*?\w+\)\s+request\s*\(' -A5

# Count all jsonBody usages
rg -n --type go '\bjsonBody\s*\('

Repository: supermodeltools/cli

Length of output: 642


🏁 Script executed:

sed -n '370,378p' internal/api/client.go

Repository: supermodeltools/cli

Length of output: 379


🏁 Script executed:

# Find ShareRequest definition
rg -n --type go 'type\s+ShareRequest\s+struct' -A10

# Check for other similar json.Marshal error ignoring patterns
rg -n --type go 'json\.(Marshal|Unmarshal)\s*\([^)]+\)\s*$' -A1

Repository: supermodeltools/cli

Length of output: 1997


Don't ignore JSON marshal errors in jsonBody.

Right now if encoding fails, you're sending an empty/broken body downstream and hiding the root cause. It's not great practice—even though ShareRequest is just strings and unlikely to fail in practice, this pattern elsewhere in the codebase (like build.go lines 459 and 1165) shows inconsistent error handling. Better to fail fast and be explicit.

The suggested fix is incomplete though—changing jsonBody to return an error means you also need to refactor the call site:

Complete fix
// jsonBody function change
-func jsonBody(v any) io.Reader {
-	b, _ := json.Marshal(v)
-	return bytes.NewReader(b)
+func jsonBody(v any) (io.Reader, error) {
+	b, err := json.Marshal(v)
+	if err != nil {
+		return nil, err
+	}
+	return bytes.NewReader(b), nil
 }

// Call site in Share function change
 func (c *Client) Share(ctx context.Context, req ShareRequest) (string, error) {
 	var resp ShareResponse
+	body, err := jsonBody(req)
+	if err != nil {
+		return "", err
+	}
-	if err := c.request(ctx, "POST", "/v1/share", "application/json",
-		jsonBody(req), "", &resp); err != nil {
+	if err := c.request(ctx, "POST", "/v1/share", "application/json",
+		body, "", &resp); err != nil {
 		return "", err
 	}
 	return resp.URL, nil
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/api/client.go` around lines 381 - 385, The jsonBody function
currently swallows json.Marshal errors and returns an invalid reader; change
jsonBody signature to return (io.Reader, error), propagate and return the
marshal error instead of ignoring it, and update all call sites (e.g., where
ShareRequest builds its request body and any uses highlighted near build.go
usages) to handle the error (check it, log/return it, and avoid sending a broken
body). Ensure callers construct requests only on successful jsonBody results and
adjust any helper functions that assumed the old signature to accept and forward
the error.


func (c *Client) request(ctx context.Context, method, path, contentType string, body io.Reader, idempotencyKey string, out any) error {
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
if err != nil {
Expand Down
12 changes: 12 additions & 0 deletions internal/api/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,18 @@ type CrossDomainDependency struct {
TargetDomain string `json:"targetDomain"`
}

// ShareRequest is the payload for POST /v1/share.
type ShareRequest struct {
ProjectName string `json:"project_name"`
Status string `json:"status"`
Content string `json:"content"` // rendered Markdown report
}

// ShareResponse is returned by POST /v1/share.
type ShareResponse struct {
URL string `json:"url"`
}

// Error represents a non-2xx response from the API.
type Error struct {
StatusCode int `json:"-"`
Expand Down
1 change: 1 addition & 0 deletions internal/cache/cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ func TestGetPut_RoundTrip(t *testing.T) {
}
if got == nil {
t.Fatal("Get returned nil after Put")
return
}
if len(got.Nodes) != 1 || got.Nodes[0].ID != "n1" {
t.Errorf("round-trip nodes: got %v", got.Nodes)
Expand Down
1 change: 1 addition & 0 deletions internal/restore/restore_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,7 @@ func TestFromSupermodelIR_Empty(t *testing.T) {
g := FromSupermodelIR(&api.SupermodelIR{}, "empty")
if g == nil {
t.Fatal("returned nil")
return
}
if g.Name != "empty" {
t.Errorf("name: got %q", g.Name)
Expand Down
Loading