diff --git a/cmd/share.go b/cmd/share.go new file mode 100644 index 0000000..8b5aa81 --- /dev/null +++ b/cmd/share.go @@ -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). diff --git a/internal/api/client.go b/internal/api/client.go index f375289..781f788 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -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 +} + +// 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) +} + 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 { diff --git a/internal/api/types.go b/internal/api/types.go index 08c802d..d5a61f2 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -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:"-"` diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index 52af72f..0650f7a 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -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) diff --git a/internal/restore/restore_test.go b/internal/restore/restore_test.go index 94526e7..6423c05 100644 --- a/internal/restore/restore_test.go +++ b/internal/restore/restore_test.go @@ -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)