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>
340 lines
10 KiB
Go
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))
|
|
}
|
|
}
|