cli/cmd/ninja/cmd/plugin_gallery.go
Alex Dunmow 42789ffd0f refactor: migrate plugin SDK imports from block/core to block/pluginsdk
Rewrite import prefix git.dev.alexdunmow.com/block/core/{plugin,blocks,
templates,abi} to git.dev.alexdunmow.com/block/pluginsdk/* across the CLI's
plugin build/verify logic (internal/bnp), plugin commands (cmd/ninja/cmd),
and the wasm build fixtures (testdata/fixture, testdata/capfixture). go.mod
adds pluginsdk@v0.1.0; core is retained only for cmd/ninja/cmd/theme.go
(ParseModFull), which is left untouched as in-flight work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:39:22 +08:00

340 lines
10 KiB
Go

package cmd
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"connectrpc.com/connect"
"github.com/spf13/cobra"
core "git.dev.alexdunmow.com/block/pluginsdk/plugin"
v1 "git.dev.alexdunmow.com/block/cli/internal/api/orchestrator/v1"
"git.dev.alexdunmow.com/block/cli/internal/creds"
"git.dev.alexdunmow.com/block/cli/internal/orchclient"
)
// maxScreenshotBytes mirrors the orchestrator's UploadScreenshot read cap
// (connect.WithReadMaxBytes(8<<20)); we reject oversize files client-side so
// the failure is a clear message instead of a truncated-stream RPC error.
const maxScreenshotBytes = 8 << 20
// newPluginGalleryCmd builds the `ninja plugin gallery` command group for
// curating a plugin/theme's registry screenshot gallery. Reads are public;
// writes require scope membership (or owning-account membership for a private
// plugin) — the orchestrator enforces this per-handler. The target plugin
// defaults to the one described by plugin.mod in the working directory; the
// --scope/--name flags override the coordinates so the gallery of any plugin
// you can manage can be curated from anywhere.
func newPluginGalleryCmd() *cobra.Command {
var scopeFlag, nameFlag string
cmd := &cobra.Command{
Use: "gallery",
Short: "Manage a plugin's registry screenshot gallery",
Long: `Curate the screenshot gallery shown on a plugin or theme's registry
detail page.
The target plugin defaults to the one described by plugin.mod in the current
directory. Use --scope/--name to target a different plugin.
Subcommands:
list Show the current gallery (public read)
add <image> Upload an image (png/jpg/jpeg/webp, <=8MiB, <=12 total)
remove <screenshot-id> Delete a screenshot
reorder <id>... Reorder the gallery (must list every current image once)
set-featured <id> Mark one screenshot featured (drives the card preview)`,
}
cmd.PersistentFlags().StringVar(&scopeFlag, "scope", "", "Target scope slug (default: plugin.mod scope)")
cmd.PersistentFlags().StringVar(&nameFlag, "name", "", "Target plugin name (default: plugin.mod name)")
cmd.AddCommand(
newGalleryListCmd(),
newGalleryAddCmd(),
newGalleryRemoveCmd(),
newGalleryReorderCmd(),
newGallerySetFeaturedCmd(),
)
return cmd
}
// galleryTarget is the resolved address of a plugin's gallery: a bare scope
// slug (no leading @), the plugin name, and — for a private plugin — the
// active account it belongs to.
type galleryTarget struct {
scopeSlug string
name string
private bool
accountID string
}
// resolveGalleryTarget derives the target plugin from plugin.mod (when present
// in the working directory) with --scope/--name overrides. A missing plugin.mod
// is not fatal as long as the flags supply scope+name.
func resolveGalleryTarget(c *cobra.Command, hc creds.HostCreds) (galleryTarget, error) {
scopeFlag, _ := c.Flags().GetString("scope")
nameFlag, _ := c.Flags().GetString("name")
var scope, name string
var private bool
if mod, err := readLocalMod(); err == nil {
scope = mod.Plugin.Scope
name = mod.Plugin.Name
private = mod.Plugin.Private
}
// Flag overrides. An explicit --scope names a public scope; the @private
// namespace is reached only via plugin.mod's private flag or by naming the
// private scope directly.
if scopeFlag != "" {
scope = scopeFlag
private = false
}
if nameFlag != "" {
name = nameFlag
}
scopeSlug := strings.TrimPrefix(strings.TrimSpace(scope), "@")
if scopeSlug == core.PrivateScopeSlug {
private = true
}
t := galleryTarget{scopeSlug: scopeSlug, name: strings.TrimSpace(name), private: private, accountID: hc.ActiveAccountID}
if t.name == "" {
return t, fmt.Errorf("no plugin name: run inside a plugin dir (with plugin.mod) or pass --name")
}
if t.private {
t.scopeSlug = core.PrivateScopeSlug
if t.accountID == "" {
return t, fmt.Errorf("private plugin gallery requires an active account; run `ninja account set <slug>`")
}
} else if t.scopeSlug == "" {
return t, fmt.Errorf("no scope: run inside a plugin dir (with plugin.mod) or pass --scope")
}
return t, nil
}
// resolvePluginID looks up the target's registry plugin ID, which every write
// RPC keys on. Mirrors the publish flow's GetPlugin call (private plugins are
// addressed by the @private scope plus the active account).
func (t galleryTarget) resolvePluginID(ctx context.Context, cli *orchclient.Client) (string, error) {
req := &v1.GetPluginRequest{ScopeSlug: t.scopeSlug, Name: t.name}
if t.private {
req.ScopeSlug = core.PrivateScopeSlug
req.ActiveAccountId = t.accountID
}
pr, err := cli.Reg.GetPlugin(ctx, connect.NewRequest(req))
if err != nil {
return "", fmt.Errorf("get plugin: %w", err)
}
return pr.Msg.Plugin.Id, nil
}
func newGalleryListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "Show the current gallery for the target plugin",
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
cli, _, _, hc, err := resolveClient(c)
if err != nil {
return err
}
t, err := resolveGalleryTarget(c, hc)
if err != nil {
return err
}
resp, err := cli.Gallery.ListScreenshots(context.Background(), connect.NewRequest(&v1.ListScreenshotsRequest{
ScopeSlug: t.scopeSlug,
PluginName: t.name,
}))
if err != nil {
return fmt.Errorf("list screenshots: %w", err)
}
shots := resp.Msg.Screenshots
if len(shots) == 0 {
fmt.Println("(no screenshots — add one with `ninja plugin gallery add <image>`)")
return nil
}
for _, s := range shots {
marker := " "
if s.Featured {
marker = "*"
}
fmt.Printf("%2d.%s %s %s %s\n", s.Position, marker, s.Id, s.ContentType, s.Url)
}
fmt.Println("\n(* = featured — drives the registry card preview image)")
return nil
},
}
}
func newGalleryAddCmd() *cobra.Command {
return &cobra.Command{
Use: "add <image>",
Short: "Upload an image to the gallery (png/jpg/jpeg/webp, <=8MiB)",
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
cli, _, _, hc, err := resolveClient(c)
if err != nil {
return err
}
t, err := resolveGalleryTarget(c, hc)
if err != nil {
return err
}
path := args[0]
contentType, err := imageContentType(path)
if err != nil {
return err
}
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read image: %w", err)
}
if len(data) == 0 {
return fmt.Errorf("%s is empty", path)
}
if len(data) > maxScreenshotBytes {
return fmt.Errorf("%s is %d bytes; the gallery limit is %d (8MiB)", path, len(data), maxScreenshotBytes)
}
ctx := context.Background()
pluginID, err := t.resolvePluginID(ctx, cli)
if err != nil {
return err
}
resp, err := cli.Gallery.UploadScreenshot(ctx, connect.NewRequest(&v1.UploadScreenshotRequest{
PluginId: pluginID,
Image: data,
Filename: filepath.Base(path),
ContentType: contentType,
}))
if err != nil {
return fmt.Errorf("upload screenshot: %w", err)
}
s := resp.Msg.Screenshot
fmt.Printf("Uploaded %s (id=%s, position=%d)\n", filepath.Base(path), s.Id, s.Position)
return nil
},
}
}
func newGalleryRemoveCmd() *cobra.Command {
return &cobra.Command{
Use: "remove <screenshot-id>",
Short: "Delete a screenshot from the gallery",
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
cli, _, _, hc, err := resolveClient(c)
if err != nil {
return err
}
t, err := resolveGalleryTarget(c, hc)
if err != nil {
return err
}
ctx := context.Background()
pluginID, err := t.resolvePluginID(ctx, cli)
if err != nil {
return err
}
if _, err := cli.Gallery.DeleteScreenshot(ctx, connect.NewRequest(&v1.DeleteScreenshotRequest{
PluginId: pluginID,
ScreenshotId: args[0],
})); err != nil {
return fmt.Errorf("delete screenshot: %w", err)
}
fmt.Printf("Deleted screenshot %s\n", args[0])
return nil
},
}
}
func newGalleryReorderCmd() *cobra.Command {
return &cobra.Command{
Use: "reorder <screenshot-id>...",
Short: "Reorder the gallery (list every current screenshot id exactly once)",
Args: cobra.MinimumNArgs(1),
RunE: func(c *cobra.Command, args []string) error {
cli, _, _, hc, err := resolveClient(c)
if err != nil {
return err
}
t, err := resolveGalleryTarget(c, hc)
if err != nil {
return err
}
ctx := context.Background()
pluginID, err := t.resolvePluginID(ctx, cli)
if err != nil {
return err
}
resp, err := cli.Gallery.ReorderScreenshots(ctx, connect.NewRequest(&v1.ReorderScreenshotsRequest{
PluginId: pluginID,
ScreenshotIdsInOrder: args,
}))
if err != nil {
return fmt.Errorf("reorder screenshots: %w", err)
}
fmt.Println("Reordered. New order:")
for _, s := range resp.Msg.Screenshots {
marker := " "
if s.Featured {
marker = "*"
}
fmt.Printf("%2d.%s %s\n", s.Position, marker, s.Id)
}
return nil
},
}
}
func newGallerySetFeaturedCmd() *cobra.Command {
return &cobra.Command{
Use: "set-featured <screenshot-id>",
Short: "Mark one screenshot featured (drives the registry card preview)",
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
cli, _, _, hc, err := resolveClient(c)
if err != nil {
return err
}
t, err := resolveGalleryTarget(c, hc)
if err != nil {
return err
}
ctx := context.Background()
pluginID, err := t.resolvePluginID(ctx, cli)
if err != nil {
return err
}
if _, err := cli.Gallery.SetFeaturedScreenshot(ctx, connect.NewRequest(&v1.SetFeaturedScreenshotRequest{
PluginId: pluginID,
ScreenshotId: args[0],
})); err != nil {
return fmt.Errorf("set featured screenshot: %w", err)
}
fmt.Printf("Set screenshot %s as featured\n", args[0])
return nil
},
}
}
// imageContentType maps a file's extension to the content type the gallery
// accepts, rejecting anything outside the png/jpg/jpeg/webp allowlist the
// orchestrator enforces. Detecting from the extension (rather than sniffing
// bytes) keeps the CLI's rejection message aligned with the server's rule.
func imageContentType(path string) (string, error) {
switch strings.ToLower(filepath.Ext(path)) {
case ".png":
return "image/png", nil
case ".jpg", ".jpeg":
return "image/jpeg", nil
case ".webp":
return "image/webp", nil
default:
return "", fmt.Errorf("unsupported image type %q: gallery accepts png, jpg, jpeg, webp", filepath.Ext(path))
}
}