Compare commits

...

3 Commits

Author SHA1 Message Date
Alex Dunmow
b5e39121d2 feat(cli): ninja plugin gallery commands (Phase 6)
Add `ninja plugin gallery list|add|remove|reorder|set-featured` for
author-side curation of a plugin/theme's registry screenshot gallery,
feeding the browse/detail surfaces shipped in Phases 4-5.

- Re-vendor plugin_registry.proto from orchestrator (adds
  PluginGalleryService) and regenerate the connect client.
- Wire the Gallery client into orchclient.Client.
- Target plugin defaults from plugin.mod with --scope/--name overrides;
  device-flow auth via resolveClient. list is a public read; add/remove/
  reorder/set-featured are RoleUser (scope/account membership enforced
  server-side). add derives content_type from the extension against the
  png/jpg/jpeg/webp allowlist and rejects >8MiB client-side.

Scope handling trims a leading @ client-side because ListScreenshots does
not trim server-side (GetPlugin does). Live-verified against the dev
orchestrator; go build + go test ./... + check-safety all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 12:35:09 +08:00
Alex Dunmow
64d81bd93f docs: add CLAUDE.md (build/install, .bnp packing, vendored proto, dev vs prod host)
The make install landmine (stale binary republishes themes without preview.png
and wipes the live preview), the zstd-tar .bnp format, publish-builds-the-artifact,
the vendored-not-submodule orchestrator proto workflow, and the prod-by-default
--host all live in one place for the next agent working in here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 09:20:17 +08:00
Alex Dunmow
e8aba45f21 feat(bnp): pack preview.png + screenshots/ into artifacts; multi-page capture
The .bnp packers never carried preview.png (the .so-era source archives did
implicitly), so any republish silently wiped a theme's registry preview.
Both builders now pack an optional root preview.png and a screenshots/ dir
(png/jpg/jpeg/webp, <=8MB each, <=12 files, validated), reported in the
build/verify summaries. ninja theme screenshot gains --pages/--screenshots-dir
to capture NN-<slug>.png per gallery page for the registry gallery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 08:21:35 +08:00
15 changed files with 4421 additions and 436 deletions

94
CLAUDE.md Normal file
View File

@ -0,0 +1,94 @@
# `ninja` — BlockNinja developer CLI
The `ninja` command: build/verify/publish CMS plugins & themes, drive the plugin
registry, and capture theme preview screenshots. Its own repo since WO-WZ-023
(remote `git.dev.alexdunmow.com/block/cli`); formerly lived inside `cms`.
Go module `git.dev.alexdunmow.com/block/cli` (Go 1.26). Cobra CLI. Talks to the
orchestrator over Connect-RPC; builds `.bnp` artifacts with `block/core` +
wazero; screenshots with chromedp.
## Build / install / test
```bash
make build # → ./bin/ninja
make install # go install ./cmd/ninja → $GOBIN (yields `ninja` on PATH)
make test # go test ./... (compiles guest wasm fixtures: needs GOOS=wasip1 + core proxy)
make proto # regenerate the vendored orchestrator client (see below)
make safety-check # cd ../check-safety && go run . ../cli
```
> **`make install` matters.** `ninja plugin publish` / `ninja plugin build` use
> the binary on your PATH, not the repo. After changing build/pack behaviour you
> MUST `make install` before any publish or the old binary ships. The classic
> failure: an installed binary that predates `.bnp` preview-packing silently
> republishes a theme **without** its `preview.png`, wiping the live preview.
> Verify with `ninja theme screenshot --help | grep screenshots-dir` and by
> extracting a fresh `.bnp` (`ninja plugin verify <file.bnp>``preview=true`).
## Command surface
- `ninja plugin build | verify <f.bnp> | publish | init | status | bump [major|minor|patch]`
- `ninja plugin list | delete | delete-version | pull | version | abi`
- `ninja plugin tags add|rm|set|clear`
- `ninja scope create|default|set|list`
- `ninja account list|set|show` · `ninja login | whoami | logout`
- `ninja theme screenshot` (preview + `--pages`/`--screenshots-dir` multi-page capture)
## `.bnp` artifacts
A `.bnp` is a **zstd-compressed tar** (NOT gzip — `tar tzf` won't read it; use
`tar tf` or `ninja plugin verify`). `internal/bnp` owns build+verify; wasm
plugins and codeless plugins/themes both pack into one. Optionally packs a root
`preview.png` and a `screenshots/` dir (validated: png/jpg/jpeg/webp, ≤8MB/file,
≤12 files, no subdirs). `internal/shot` renders pages via chromedp for
`ninja theme screenshot`.
**Publish builds the artifact itself.** `ninja plugin publish` builds the `.bnp`
and ships it — the artifact is the ONLY publish form (the legacy source-archive
path is deleted). `--bnp <file>` ships a prebuilt one. Instances download the
artifact and never compile.
## Orchestrator target (dev vs prod)
Global `--host` flag: orchestrator base URL. **Default is
`https://my.blockninjacms.com` — PRODUCTION.** For local dev always pass
`--host https://my.localdev.blockninjacms.com` (or set it as the active host via
`ninja login`). Never publish dev/test plugins to the prod registry by omitting
`--host`.
Auth is **device flow** (`ninja login``AuthService.StartDevice`/`PollDevice`).
Credentials (per-host token + active account) persist to
`<os.UserConfigDir>/ninja/credentials.json` (Linux: `~/.config/ninja/…`).
`internal/creds` reads/writes it; `internal/orchclient` builds the Connect client.
## Vendored orchestrator proto (NOT a submodule)
`proto/orchestrator/v1/plugin_registry.proto` is a **hand-vendored COPY** of the
orchestrator's proto, not a git submodule. `make proto` regenerates
`internal/api/orchestrator/v1/` from it. `buf.gen.yaml` runs in **managed mode**
and overrides `go_package_prefix``git.dev.alexdunmow.com/block/cli/internal/api`
so the generated Go lands in this module regardless of the proto's declared
`go_package`. Needs `buf` + `protoc-gen-go` + `protoc-gen-connect-go` on PATH.
**To pick up new orchestrator RPCs** (e.g. registry gallery/review methods): copy
the updated `plugin_registry.proto` (and any new proto) from the orchestrator's
proto into `proto/`, run `make proto`, then add client methods in
`internal/orchclient`. There is no automatic sync — the copy is deliberate so the
CLI pins a known server surface.
## Workspace couplings
- `make safety-check` shells into the sibling `../check-safety` tool — the CLI is
scanned like the other repos. Run it before publishing CLI changes.
- Depends on `git.dev.alexdunmow.com/block/core` (the plugin SDK) via the Gitea
module proxy. **Never** use `replace` directives — tag/push `core`, then bump.
- The `.bnp` format + pack/validation rules are shared truth with the CMS host
loader and the orchestrator registry ingestion; changing them is a cross-repo
contract change.
## Working in here
- Standalone repo: commit/push only within `cli/`. Work on `main`.
- Ask before publishing anything to a registry — publish is outward-facing and
hits a real orchestrator (prod by default).

View File

@ -37,6 +37,7 @@ func newPluginCmd() *cobra.Command {
newPluginBumpCmd(), newPluginBumpCmd(),
newPluginVersionCmd(), newPluginVersionCmd(),
newPluginTagsCmd(), newPluginTagsCmd(),
newPluginGalleryCmd(),
) )
return c return c
} }

View File

@ -98,6 +98,8 @@ func printBuildSummary(r *bnp.BuildResult) {
fmt.Printf(" │ job types %d\n", r.JobTypes) fmt.Printf(" │ job types %d\n", r.JobTypes)
fmt.Printf(" │ hooks %s\n", hooks) fmt.Printf(" │ hooks %s\n", hooks)
fmt.Printf(" │ bundled dirs %s\n", dirs) fmt.Printf(" │ bundled dirs %s\n", dirs)
fmt.Printf(" │ preview.png %t\n", r.PreviewIncluded)
fmt.Printf(" │ screenshots %d\n", r.ScreenshotCount)
fmt.Printf(" │ data_dir grant %t\n", r.DataDir) fmt.Printf(" │ data_dir grant %t\n", r.DataDir)
fmt.Println(" └───────────────────────────────────────────") fmt.Println(" └───────────────────────────────────────────")
} }
@ -121,8 +123,8 @@ func printVerifySummary(path string, r *bnp.VerifyResult) {
joined = strings.Join(dirs, ", ") joined = strings.Join(dirs, ", ")
} }
fmt.Printf("OK: %s\n", path) fmt.Printf("OK: %s\n", path)
fmt.Printf(" name=%s version=%s abi_version=%d blocks=%d data_dir=%t dirs=[%s]\n", fmt.Printf(" name=%s version=%s abi_version=%d blocks=%d data_dir=%t dirs=[%s] preview=%t screenshots=%d\n",
r.Name, r.Version, r.ABIVersion, r.BlockCount, r.DataDir, joined) r.Name, r.Version, r.ABIVersion, r.BlockCount, r.DataDir, joined, r.HasPreview, r.ScreenshotCount)
} }
// humanBytes formats a byte count with a binary unit suffix. // humanBytes formats a byte count with a binary unit suffix.

View File

@ -0,0 +1,339 @@
package cmd
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"connectrpc.com/connect"
"github.com/spf13/cobra"
core "git.dev.alexdunmow.com/block/core/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))
}
}

View File

@ -0,0 +1,191 @@
package cmd
import (
"os"
"path/filepath"
"testing"
"github.com/spf13/cobra"
core "git.dev.alexdunmow.com/block/core/plugin"
"git.dev.alexdunmow.com/block/cli/internal/creds"
)
func TestImageContentType(t *testing.T) {
cases := []struct {
path string
want string
wantErr bool
}{
{path: "preview.png", want: "image/png"},
{path: "shot.jpg", want: "image/jpeg"},
{path: "shot.jpeg", want: "image/jpeg"},
{path: "shot.webp", want: "image/webp"},
{path: "SHOT.PNG", want: "image/png"}, // case-insensitive
{path: "dir/nested/x.jpg", want: "image/jpeg"},
{path: "shot.gif", wantErr: true},
{path: "shot.svg", wantErr: true},
{path: "noext", wantErr: true},
}
for _, c := range cases {
got, err := imageContentType(c.path)
if c.wantErr {
if err == nil {
t.Errorf("imageContentType(%q) = %q, want error", c.path, got)
}
continue
}
if err != nil {
t.Errorf("imageContentType(%q) err: %v", c.path, err)
continue
}
if got != c.want {
t.Errorf("imageContentType(%q) = %q, want %q", c.path, got, c.want)
}
}
}
// galleryTestCmd builds a command carrying the --scope/--name flags exactly as
// the gallery parent registers them, so resolveGalleryTarget can read them.
func galleryTestCmd(scope, name string) *cobra.Command {
c := &cobra.Command{Use: "gallery"}
c.Flags().String("scope", "", "")
c.Flags().String("name", "", "")
if scope != "" {
_ = c.Flags().Set("scope", scope)
}
if name != "" {
_ = c.Flags().Set("name", name)
}
return c
}
func writeGalleryMod(t *testing.T, dir, scope, name string, private bool) {
t.Helper()
m := &core.ModFile{Plugin: core.ModPlugin{
Name: name,
Scope: scope,
Version: "0.1.0",
Private: private,
}}
if err := writeMod(filepath.Join(dir, "plugin.mod"), m); err != nil {
t.Fatalf("writeMod: %v", err)
}
}
func TestResolveGalleryTarget_FromMod(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
writeGalleryMod(t, dir, "@themes", "gotham", false)
got, err := resolveGalleryTarget(galleryTestCmd("", ""), creds.HostCreds{})
if err != nil {
t.Fatalf("resolveGalleryTarget: %v", err)
}
// Leading @ must be trimmed (ListScreenshots does not trim server-side).
if got.scopeSlug != "themes" || got.name != "gotham" || got.private {
t.Errorf("got %+v, want scopeSlug=themes name=gotham private=false", got)
}
}
func TestResolveGalleryTarget_FlagOverride(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
writeGalleryMod(t, dir, "@themes", "gotham", false)
got, err := resolveGalleryTarget(galleryTestCmd("acme", "widget"), creds.HostCreds{})
if err != nil {
t.Fatalf("resolveGalleryTarget: %v", err)
}
if got.scopeSlug != "acme" || got.name != "widget" {
t.Errorf("got %+v, want scopeSlug=acme name=widget", got)
}
}
func TestResolveGalleryTarget_NoModNeedsFlags(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir) // no plugin.mod written
// Flags supply both coordinates → resolves without a plugin.mod.
got, err := resolveGalleryTarget(galleryTestCmd("acme", "widget"), creds.HostCreds{})
if err != nil {
t.Fatalf("resolveGalleryTarget with flags: %v", err)
}
if got.scopeSlug != "acme" || got.name != "widget" {
t.Errorf("got %+v, want scopeSlug=acme name=widget", got)
}
// No plugin.mod and no flags → an actionable error.
if _, err := resolveGalleryTarget(galleryTestCmd("", ""), creds.HostCreds{}); err == nil {
t.Error("expected error with no plugin.mod and no flags")
}
}
func TestResolveGalleryTarget_MissingScope(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
// Name but no scope (public) → error naming the missing scope.
_, err := resolveGalleryTarget(galleryTestCmd("", "widget"), creds.HostCreds{})
if err == nil {
t.Fatal("expected error for name without scope")
}
}
func TestResolveGalleryTarget_PrivateFromMod(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
writeGalleryMod(t, dir, "", "widget", true)
// Private plugin without an active account → error.
if _, err := resolveGalleryTarget(galleryTestCmd("", ""), creds.HostCreds{}); err == nil {
t.Error("expected error: private gallery needs an active account")
}
// With an active account, the scope resolves to the @private namespace.
got, err := resolveGalleryTarget(galleryTestCmd("", ""), creds.HostCreds{ActiveAccountID: "acct-1"})
if err != nil {
t.Fatalf("resolveGalleryTarget private: %v", err)
}
if !got.private || got.scopeSlug != core.PrivateScopeSlug || got.accountID != "acct-1" {
t.Errorf("got %+v, want private=true scopeSlug=%s accountID=acct-1", got, core.PrivateScopeSlug)
}
}
func TestResolveGalleryTarget_PrivateScopeNameImpliesPrivate(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
// Naming the private scope directly flips private on (and still needs an account).
got, err := resolveGalleryTarget(galleryTestCmd("@"+core.PrivateScopeSlug, "widget"), creds.HostCreds{ActiveAccountID: "acct-1"})
if err != nil {
t.Fatalf("resolveGalleryTarget: %v", err)
}
if !got.private || got.scopeSlug != core.PrivateScopeSlug {
t.Errorf("got %+v, want private=true scopeSlug=%s", got, core.PrivateScopeSlug)
}
}
func TestResolveGalleryTarget_ReadFileHelper(t *testing.T) {
// Guard that a valid image on disk round-trips through the same os.ReadFile
// path `add` uses (content-type derived from the extension).
dir := t.TempDir()
path := filepath.Join(dir, "preview.png")
if err := os.WriteFile(path, []byte("\x89PNG\r\n\x1a\nfake"), 0o644); err != nil {
t.Fatal(err)
}
ct, err := imageContentType(path)
if err != nil {
t.Fatalf("imageContentType: %v", err)
}
if ct != "image/png" {
t.Errorf("content type = %q, want image/png", ct)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read: %v", err)
}
if len(data) == 0 || len(data) > maxScreenshotBytes {
t.Errorf("unexpected size %d", len(data))
}
}

View File

@ -4,6 +4,8 @@ import (
"context" "context"
"fmt" "fmt"
"os" "os"
"path/filepath"
"strings"
"time" "time"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@ -21,6 +23,7 @@ func newThemeCmd() *cobra.Command {
func newThemeScreenshotCmd() *cobra.Command { func newThemeScreenshotCmd() *cobra.Command {
var gallery, slug, out, mobileOut, themeName, waitSelector string var gallery, slug, out, mobileOut, themeName, waitSelector string
var pages, screenshotsDir string
var mobile bool var mobile bool
var width, height int var width, height int
cmd := &cobra.Command{ cmd := &cobra.Command{
@ -92,6 +95,39 @@ the rest of the CLI.`,
} }
fmt.Printf("wrote %s (%d bytes)\n", mobileOut, len(mpng)) fmt.Printf("wrote %s (%d bytes)\n", mobileOut, len(mpng))
} }
// Multi-page gallery capture (--pages): one PNG per slug into
// --screenshots-dir, named NN-<slug>.png. These are the source
// files the .bnp packer ships under screenshots/ and the registry
// ingests into the plugin gallery at publish.
if pages != "" {
if err := os.MkdirAll(screenshotsDir, 0o755); err != nil {
return fmt.Errorf("create %s: %w", screenshotsDir, err)
}
for i, pageSlug := range strings.Split(pages, ",") {
pageSlug = strings.TrimSpace(pageSlug)
if pageSlug == "" {
continue
}
pURL := shot.PreviewURL(gallery, pageSlug, themeName)
fmt.Fprintf(os.Stderr, "capturing page %s: %s\n", pageSlug, pURL)
ppng, err := shot.Capture(ctx, shot.Options{
URL: pURL,
Width: width,
Height: height,
WaitSelector: waitSelector,
Timeout: 45 * time.Second,
})
if err != nil {
return err
}
dest := filepath.Join(screenshotsDir, fmt.Sprintf("%02d-%s.png", i+1, slugFileName(pageSlug)))
if err := os.WriteFile(dest, ppng, 0o644); err != nil {
return fmt.Errorf("write %s: %w", dest, err)
}
fmt.Printf("wrote %s (%d bytes)\n", dest, len(ppng))
}
}
return nil return nil
}, },
} }
@ -104,5 +140,27 @@ the rest of the CLI.`,
cmd.Flags().BoolVar(&mobile, "mobile", false, "Also capture preview-mobile.png at a phone viewport") cmd.Flags().BoolVar(&mobile, "mobile", false, "Also capture preview-mobile.png at a phone viewport")
cmd.Flags().IntVar(&width, "width", 1440, "Desktop viewport width") cmd.Flags().IntVar(&width, "width", 1440, "Desktop viewport width")
cmd.Flags().IntVar(&height, "height", 900, "Desktop viewport height") cmd.Flags().IntVar(&height, "height", 900, "Desktop viewport height")
cmd.Flags().StringVar(&pages, "pages", "", "Comma-separated page slugs to capture into --screenshots-dir (e.g. /,/blog,/about)")
cmd.Flags().StringVar(&screenshotsDir, "screenshots-dir", "screenshots", "Directory for --pages captures (packed into the .bnp)")
return cmd return cmd
} }
// slugFileName turns a page slug into a filename fragment ("/" → "home",
// "/blog/post" → "blog-post").
func slugFileName(slug string) string {
s := strings.Trim(slug, "/")
if s == "" {
return "home"
}
s = strings.ReplaceAll(s, "/", "-")
var b strings.Builder
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
b.WriteRune(r)
default:
b.WriteRune('-')
}
}
return b.String()
}

View File

@ -27,6 +27,10 @@ const (
PluginRegistryServiceName = "orchestrator.v1.PluginRegistryService" PluginRegistryServiceName = "orchestrator.v1.PluginRegistryService"
// PluginModerationServiceName is the fully-qualified name of the PluginModerationService service. // PluginModerationServiceName is the fully-qualified name of the PluginModerationService service.
PluginModerationServiceName = "orchestrator.v1.PluginModerationService" PluginModerationServiceName = "orchestrator.v1.PluginModerationService"
// PluginGalleryServiceName is the fully-qualified name of the PluginGalleryService service.
PluginGalleryServiceName = "orchestrator.v1.PluginGalleryService"
// PluginReviewServiceName is the fully-qualified name of the PluginReviewService service.
PluginReviewServiceName = "orchestrator.v1.PluginReviewService"
// PluginPublishServiceName is the fully-qualified name of the PluginPublishService service. // PluginPublishServiceName is the fully-qualified name of the PluginPublishService service.
PluginPublishServiceName = "orchestrator.v1.PluginPublishService" PluginPublishServiceName = "orchestrator.v1.PluginPublishService"
// PluginAuthServiceName is the fully-qualified name of the PluginAuthService service. // PluginAuthServiceName is the fully-qualified name of the PluginAuthService service.
@ -107,6 +111,51 @@ const (
// PluginModerationServiceRequestChangesProcedure is the fully-qualified name of the // PluginModerationServiceRequestChangesProcedure is the fully-qualified name of the
// PluginModerationService's RequestChanges RPC. // PluginModerationService's RequestChanges RPC.
PluginModerationServiceRequestChangesProcedure = "/orchestrator.v1.PluginModerationService/RequestChanges" PluginModerationServiceRequestChangesProcedure = "/orchestrator.v1.PluginModerationService/RequestChanges"
// PluginModerationServiceListFlaggedReviewsProcedure is the fully-qualified name of the
// PluginModerationService's ListFlaggedReviews RPC.
PluginModerationServiceListFlaggedReviewsProcedure = "/orchestrator.v1.PluginModerationService/ListFlaggedReviews"
// PluginModerationServiceSoftDeleteReviewProcedure is the fully-qualified name of the
// PluginModerationService's SoftDeleteReview RPC.
PluginModerationServiceSoftDeleteReviewProcedure = "/orchestrator.v1.PluginModerationService/SoftDeleteReview"
// PluginModerationServiceSoftDeleteReplyProcedure is the fully-qualified name of the
// PluginModerationService's SoftDeleteReply RPC.
PluginModerationServiceSoftDeleteReplyProcedure = "/orchestrator.v1.PluginModerationService/SoftDeleteReply"
// PluginGalleryServiceListScreenshotsProcedure is the fully-qualified name of the
// PluginGalleryService's ListScreenshots RPC.
PluginGalleryServiceListScreenshotsProcedure = "/orchestrator.v1.PluginGalleryService/ListScreenshots"
// PluginGalleryServiceUploadScreenshotProcedure is the fully-qualified name of the
// PluginGalleryService's UploadScreenshot RPC.
PluginGalleryServiceUploadScreenshotProcedure = "/orchestrator.v1.PluginGalleryService/UploadScreenshot"
// PluginGalleryServiceDeleteScreenshotProcedure is the fully-qualified name of the
// PluginGalleryService's DeleteScreenshot RPC.
PluginGalleryServiceDeleteScreenshotProcedure = "/orchestrator.v1.PluginGalleryService/DeleteScreenshot"
// PluginGalleryServiceReorderScreenshotsProcedure is the fully-qualified name of the
// PluginGalleryService's ReorderScreenshots RPC.
PluginGalleryServiceReorderScreenshotsProcedure = "/orchestrator.v1.PluginGalleryService/ReorderScreenshots"
// PluginGalleryServiceSetFeaturedScreenshotProcedure is the fully-qualified name of the
// PluginGalleryService's SetFeaturedScreenshot RPC.
PluginGalleryServiceSetFeaturedScreenshotProcedure = "/orchestrator.v1.PluginGalleryService/SetFeaturedScreenshot"
// PluginReviewServiceListReviewsProcedure is the fully-qualified name of the PluginReviewService's
// ListReviews RPC.
PluginReviewServiceListReviewsProcedure = "/orchestrator.v1.PluginReviewService/ListReviews"
// PluginReviewServiceGetMyReviewProcedure is the fully-qualified name of the PluginReviewService's
// GetMyReview RPC.
PluginReviewServiceGetMyReviewProcedure = "/orchestrator.v1.PluginReviewService/GetMyReview"
// PluginReviewServiceUpsertReviewProcedure is the fully-qualified name of the PluginReviewService's
// UpsertReview RPC.
PluginReviewServiceUpsertReviewProcedure = "/orchestrator.v1.PluginReviewService/UpsertReview"
// PluginReviewServiceDeleteReviewProcedure is the fully-qualified name of the PluginReviewService's
// DeleteReview RPC.
PluginReviewServiceDeleteReviewProcedure = "/orchestrator.v1.PluginReviewService/DeleteReview"
// PluginReviewServiceReplyToReviewProcedure is the fully-qualified name of the
// PluginReviewService's ReplyToReview RPC.
PluginReviewServiceReplyToReviewProcedure = "/orchestrator.v1.PluginReviewService/ReplyToReview"
// PluginReviewServiceDeleteReplyProcedure is the fully-qualified name of the PluginReviewService's
// DeleteReply RPC.
PluginReviewServiceDeleteReplyProcedure = "/orchestrator.v1.PluginReviewService/DeleteReply"
// PluginReviewServiceFlagReviewProcedure is the fully-qualified name of the PluginReviewService's
// FlagReview RPC.
PluginReviewServiceFlagReviewProcedure = "/orchestrator.v1.PluginReviewService/FlagReview"
// PluginPublishServicePublishVersionProcedure is the fully-qualified name of the // PluginPublishServicePublishVersionProcedure is the fully-qualified name of the
// PluginPublishService's PublishVersion RPC. // PluginPublishService's PublishVersion RPC.
PluginPublishServicePublishVersionProcedure = "/orchestrator.v1.PluginPublishService/PublishVersion" PluginPublishServicePublishVersionProcedure = "/orchestrator.v1.PluginPublishService/PublishVersion"
@ -714,6 +763,11 @@ type PluginModerationServiceClient interface {
ApproveSubmission(context.Context, *connect.Request[v1.ApproveSubmissionRequest]) (*connect.Response[v1.ApproveSubmissionResponse], error) ApproveSubmission(context.Context, *connect.Request[v1.ApproveSubmissionRequest]) (*connect.Response[v1.ApproveSubmissionResponse], error)
RejectSubmission(context.Context, *connect.Request[v1.RejectSubmissionRequest]) (*connect.Response[v1.RejectSubmissionResponse], error) RejectSubmission(context.Context, *connect.Request[v1.RejectSubmissionRequest]) (*connect.Response[v1.RejectSubmissionResponse], error)
RequestChanges(context.Context, *connect.Request[v1.RequestChangesRequest]) (*connect.Response[v1.RequestChangesResponse], error) RequestChanges(context.Context, *connect.Request[v1.RequestChangesRequest]) (*connect.Response[v1.RequestChangesResponse], error)
// Star-review moderation (ADR 0021). Unrelated to the submission queue
// above: these act on user reviews (PluginReviewService), not plugins.
ListFlaggedReviews(context.Context, *connect.Request[v1.ListFlaggedReviewsRequest]) (*connect.Response[v1.ListFlaggedReviewsResponse], error)
SoftDeleteReview(context.Context, *connect.Request[v1.SoftDeleteReviewRequest]) (*connect.Response[v1.SoftDeleteReviewResponse], error)
SoftDeleteReply(context.Context, *connect.Request[v1.SoftDeleteReplyRequest]) (*connect.Response[v1.SoftDeleteReplyResponse], error)
} }
// NewPluginModerationServiceClient constructs a client for the // NewPluginModerationServiceClient constructs a client for the
@ -751,6 +805,24 @@ func NewPluginModerationServiceClient(httpClient connect.HTTPClient, baseURL str
connect.WithSchema(pluginModerationServiceMethods.ByName("RequestChanges")), connect.WithSchema(pluginModerationServiceMethods.ByName("RequestChanges")),
connect.WithClientOptions(opts...), connect.WithClientOptions(opts...),
), ),
listFlaggedReviews: connect.NewClient[v1.ListFlaggedReviewsRequest, v1.ListFlaggedReviewsResponse](
httpClient,
baseURL+PluginModerationServiceListFlaggedReviewsProcedure,
connect.WithSchema(pluginModerationServiceMethods.ByName("ListFlaggedReviews")),
connect.WithClientOptions(opts...),
),
softDeleteReview: connect.NewClient[v1.SoftDeleteReviewRequest, v1.SoftDeleteReviewResponse](
httpClient,
baseURL+PluginModerationServiceSoftDeleteReviewProcedure,
connect.WithSchema(pluginModerationServiceMethods.ByName("SoftDeleteReview")),
connect.WithClientOptions(opts...),
),
softDeleteReply: connect.NewClient[v1.SoftDeleteReplyRequest, v1.SoftDeleteReplyResponse](
httpClient,
baseURL+PluginModerationServiceSoftDeleteReplyProcedure,
connect.WithSchema(pluginModerationServiceMethods.ByName("SoftDeleteReply")),
connect.WithClientOptions(opts...),
),
} }
} }
@ -760,6 +832,9 @@ type pluginModerationServiceClient struct {
approveSubmission *connect.Client[v1.ApproveSubmissionRequest, v1.ApproveSubmissionResponse] approveSubmission *connect.Client[v1.ApproveSubmissionRequest, v1.ApproveSubmissionResponse]
rejectSubmission *connect.Client[v1.RejectSubmissionRequest, v1.RejectSubmissionResponse] rejectSubmission *connect.Client[v1.RejectSubmissionRequest, v1.RejectSubmissionResponse]
requestChanges *connect.Client[v1.RequestChangesRequest, v1.RequestChangesResponse] requestChanges *connect.Client[v1.RequestChangesRequest, v1.RequestChangesResponse]
listFlaggedReviews *connect.Client[v1.ListFlaggedReviewsRequest, v1.ListFlaggedReviewsResponse]
softDeleteReview *connect.Client[v1.SoftDeleteReviewRequest, v1.SoftDeleteReviewResponse]
softDeleteReply *connect.Client[v1.SoftDeleteReplyRequest, v1.SoftDeleteReplyResponse]
} }
// ListPendingReviews calls orchestrator.v1.PluginModerationService.ListPendingReviews. // ListPendingReviews calls orchestrator.v1.PluginModerationService.ListPendingReviews.
@ -782,6 +857,21 @@ func (c *pluginModerationServiceClient) RequestChanges(ctx context.Context, req
return c.requestChanges.CallUnary(ctx, req) return c.requestChanges.CallUnary(ctx, req)
} }
// ListFlaggedReviews calls orchestrator.v1.PluginModerationService.ListFlaggedReviews.
func (c *pluginModerationServiceClient) ListFlaggedReviews(ctx context.Context, req *connect.Request[v1.ListFlaggedReviewsRequest]) (*connect.Response[v1.ListFlaggedReviewsResponse], error) {
return c.listFlaggedReviews.CallUnary(ctx, req)
}
// SoftDeleteReview calls orchestrator.v1.PluginModerationService.SoftDeleteReview.
func (c *pluginModerationServiceClient) SoftDeleteReview(ctx context.Context, req *connect.Request[v1.SoftDeleteReviewRequest]) (*connect.Response[v1.SoftDeleteReviewResponse], error) {
return c.softDeleteReview.CallUnary(ctx, req)
}
// SoftDeleteReply calls orchestrator.v1.PluginModerationService.SoftDeleteReply.
func (c *pluginModerationServiceClient) SoftDeleteReply(ctx context.Context, req *connect.Request[v1.SoftDeleteReplyRequest]) (*connect.Response[v1.SoftDeleteReplyResponse], error) {
return c.softDeleteReply.CallUnary(ctx, req)
}
// PluginModerationServiceHandler is an implementation of the // PluginModerationServiceHandler is an implementation of the
// orchestrator.v1.PluginModerationService service. // orchestrator.v1.PluginModerationService service.
type PluginModerationServiceHandler interface { type PluginModerationServiceHandler interface {
@ -789,6 +879,11 @@ type PluginModerationServiceHandler interface {
ApproveSubmission(context.Context, *connect.Request[v1.ApproveSubmissionRequest]) (*connect.Response[v1.ApproveSubmissionResponse], error) ApproveSubmission(context.Context, *connect.Request[v1.ApproveSubmissionRequest]) (*connect.Response[v1.ApproveSubmissionResponse], error)
RejectSubmission(context.Context, *connect.Request[v1.RejectSubmissionRequest]) (*connect.Response[v1.RejectSubmissionResponse], error) RejectSubmission(context.Context, *connect.Request[v1.RejectSubmissionRequest]) (*connect.Response[v1.RejectSubmissionResponse], error)
RequestChanges(context.Context, *connect.Request[v1.RequestChangesRequest]) (*connect.Response[v1.RequestChangesResponse], error) RequestChanges(context.Context, *connect.Request[v1.RequestChangesRequest]) (*connect.Response[v1.RequestChangesResponse], error)
// Star-review moderation (ADR 0021). Unrelated to the submission queue
// above: these act on user reviews (PluginReviewService), not plugins.
ListFlaggedReviews(context.Context, *connect.Request[v1.ListFlaggedReviewsRequest]) (*connect.Response[v1.ListFlaggedReviewsResponse], error)
SoftDeleteReview(context.Context, *connect.Request[v1.SoftDeleteReviewRequest]) (*connect.Response[v1.SoftDeleteReviewResponse], error)
SoftDeleteReply(context.Context, *connect.Request[v1.SoftDeleteReplyRequest]) (*connect.Response[v1.SoftDeleteReplyResponse], error)
} }
// NewPluginModerationServiceHandler builds an HTTP handler from the service implementation. It // NewPluginModerationServiceHandler builds an HTTP handler from the service implementation. It
@ -822,6 +917,24 @@ func NewPluginModerationServiceHandler(svc PluginModerationServiceHandler, opts
connect.WithSchema(pluginModerationServiceMethods.ByName("RequestChanges")), connect.WithSchema(pluginModerationServiceMethods.ByName("RequestChanges")),
connect.WithHandlerOptions(opts...), connect.WithHandlerOptions(opts...),
) )
pluginModerationServiceListFlaggedReviewsHandler := connect.NewUnaryHandler(
PluginModerationServiceListFlaggedReviewsProcedure,
svc.ListFlaggedReviews,
connect.WithSchema(pluginModerationServiceMethods.ByName("ListFlaggedReviews")),
connect.WithHandlerOptions(opts...),
)
pluginModerationServiceSoftDeleteReviewHandler := connect.NewUnaryHandler(
PluginModerationServiceSoftDeleteReviewProcedure,
svc.SoftDeleteReview,
connect.WithSchema(pluginModerationServiceMethods.ByName("SoftDeleteReview")),
connect.WithHandlerOptions(opts...),
)
pluginModerationServiceSoftDeleteReplyHandler := connect.NewUnaryHandler(
PluginModerationServiceSoftDeleteReplyProcedure,
svc.SoftDeleteReply,
connect.WithSchema(pluginModerationServiceMethods.ByName("SoftDeleteReply")),
connect.WithHandlerOptions(opts...),
)
return "/orchestrator.v1.PluginModerationService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return "/orchestrator.v1.PluginModerationService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path { switch r.URL.Path {
case PluginModerationServiceListPendingReviewsProcedure: case PluginModerationServiceListPendingReviewsProcedure:
@ -832,6 +945,12 @@ func NewPluginModerationServiceHandler(svc PluginModerationServiceHandler, opts
pluginModerationServiceRejectSubmissionHandler.ServeHTTP(w, r) pluginModerationServiceRejectSubmissionHandler.ServeHTTP(w, r)
case PluginModerationServiceRequestChangesProcedure: case PluginModerationServiceRequestChangesProcedure:
pluginModerationServiceRequestChangesHandler.ServeHTTP(w, r) pluginModerationServiceRequestChangesHandler.ServeHTTP(w, r)
case PluginModerationServiceListFlaggedReviewsProcedure:
pluginModerationServiceListFlaggedReviewsHandler.ServeHTTP(w, r)
case PluginModerationServiceSoftDeleteReviewProcedure:
pluginModerationServiceSoftDeleteReviewHandler.ServeHTTP(w, r)
case PluginModerationServiceSoftDeleteReplyProcedure:
pluginModerationServiceSoftDeleteReplyHandler.ServeHTTP(w, r)
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
@ -857,6 +976,420 @@ func (UnimplementedPluginModerationServiceHandler) RequestChanges(context.Contex
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("orchestrator.v1.PluginModerationService.RequestChanges is not implemented")) return nil, connect.NewError(connect.CodeUnimplemented, errors.New("orchestrator.v1.PluginModerationService.RequestChanges is not implemented"))
} }
func (UnimplementedPluginModerationServiceHandler) ListFlaggedReviews(context.Context, *connect.Request[v1.ListFlaggedReviewsRequest]) (*connect.Response[v1.ListFlaggedReviewsResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("orchestrator.v1.PluginModerationService.ListFlaggedReviews is not implemented"))
}
func (UnimplementedPluginModerationServiceHandler) SoftDeleteReview(context.Context, *connect.Request[v1.SoftDeleteReviewRequest]) (*connect.Response[v1.SoftDeleteReviewResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("orchestrator.v1.PluginModerationService.SoftDeleteReview is not implemented"))
}
func (UnimplementedPluginModerationServiceHandler) SoftDeleteReply(context.Context, *connect.Request[v1.SoftDeleteReplyRequest]) (*connect.Response[v1.SoftDeleteReplyResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("orchestrator.v1.PluginModerationService.SoftDeleteReply is not implemented"))
}
// PluginGalleryServiceClient is a client for the orchestrator.v1.PluginGalleryService service.
type PluginGalleryServiceClient interface {
ListScreenshots(context.Context, *connect.Request[v1.ListScreenshotsRequest]) (*connect.Response[v1.ListScreenshotsResponse], error)
UploadScreenshot(context.Context, *connect.Request[v1.UploadScreenshotRequest]) (*connect.Response[v1.UploadScreenshotResponse], error)
DeleteScreenshot(context.Context, *connect.Request[v1.DeleteScreenshotRequest]) (*connect.Response[v1.DeleteScreenshotResponse], error)
ReorderScreenshots(context.Context, *connect.Request[v1.ReorderScreenshotsRequest]) (*connect.Response[v1.ReorderScreenshotsResponse], error)
SetFeaturedScreenshot(context.Context, *connect.Request[v1.SetFeaturedScreenshotRequest]) (*connect.Response[v1.SetFeaturedScreenshotResponse], error)
}
// NewPluginGalleryServiceClient constructs a client for the orchestrator.v1.PluginGalleryService
// service. By default, it uses the Connect protocol with the binary Protobuf Codec, asks for
// gzipped responses, and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply
// the connect.WithGRPC() or connect.WithGRPCWeb() options.
//
// The URL supplied here should be the base URL for the Connect or gRPC server (for example,
// http://api.acme.com or https://acme.com/grpc).
func NewPluginGalleryServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) PluginGalleryServiceClient {
baseURL = strings.TrimRight(baseURL, "/")
pluginGalleryServiceMethods := v1.File_orchestrator_v1_plugin_registry_proto.Services().ByName("PluginGalleryService").Methods()
return &pluginGalleryServiceClient{
listScreenshots: connect.NewClient[v1.ListScreenshotsRequest, v1.ListScreenshotsResponse](
httpClient,
baseURL+PluginGalleryServiceListScreenshotsProcedure,
connect.WithSchema(pluginGalleryServiceMethods.ByName("ListScreenshots")),
connect.WithClientOptions(opts...),
),
uploadScreenshot: connect.NewClient[v1.UploadScreenshotRequest, v1.UploadScreenshotResponse](
httpClient,
baseURL+PluginGalleryServiceUploadScreenshotProcedure,
connect.WithSchema(pluginGalleryServiceMethods.ByName("UploadScreenshot")),
connect.WithClientOptions(opts...),
),
deleteScreenshot: connect.NewClient[v1.DeleteScreenshotRequest, v1.DeleteScreenshotResponse](
httpClient,
baseURL+PluginGalleryServiceDeleteScreenshotProcedure,
connect.WithSchema(pluginGalleryServiceMethods.ByName("DeleteScreenshot")),
connect.WithClientOptions(opts...),
),
reorderScreenshots: connect.NewClient[v1.ReorderScreenshotsRequest, v1.ReorderScreenshotsResponse](
httpClient,
baseURL+PluginGalleryServiceReorderScreenshotsProcedure,
connect.WithSchema(pluginGalleryServiceMethods.ByName("ReorderScreenshots")),
connect.WithClientOptions(opts...),
),
setFeaturedScreenshot: connect.NewClient[v1.SetFeaturedScreenshotRequest, v1.SetFeaturedScreenshotResponse](
httpClient,
baseURL+PluginGalleryServiceSetFeaturedScreenshotProcedure,
connect.WithSchema(pluginGalleryServiceMethods.ByName("SetFeaturedScreenshot")),
connect.WithClientOptions(opts...),
),
}
}
// pluginGalleryServiceClient implements PluginGalleryServiceClient.
type pluginGalleryServiceClient struct {
listScreenshots *connect.Client[v1.ListScreenshotsRequest, v1.ListScreenshotsResponse]
uploadScreenshot *connect.Client[v1.UploadScreenshotRequest, v1.UploadScreenshotResponse]
deleteScreenshot *connect.Client[v1.DeleteScreenshotRequest, v1.DeleteScreenshotResponse]
reorderScreenshots *connect.Client[v1.ReorderScreenshotsRequest, v1.ReorderScreenshotsResponse]
setFeaturedScreenshot *connect.Client[v1.SetFeaturedScreenshotRequest, v1.SetFeaturedScreenshotResponse]
}
// ListScreenshots calls orchestrator.v1.PluginGalleryService.ListScreenshots.
func (c *pluginGalleryServiceClient) ListScreenshots(ctx context.Context, req *connect.Request[v1.ListScreenshotsRequest]) (*connect.Response[v1.ListScreenshotsResponse], error) {
return c.listScreenshots.CallUnary(ctx, req)
}
// UploadScreenshot calls orchestrator.v1.PluginGalleryService.UploadScreenshot.
func (c *pluginGalleryServiceClient) UploadScreenshot(ctx context.Context, req *connect.Request[v1.UploadScreenshotRequest]) (*connect.Response[v1.UploadScreenshotResponse], error) {
return c.uploadScreenshot.CallUnary(ctx, req)
}
// DeleteScreenshot calls orchestrator.v1.PluginGalleryService.DeleteScreenshot.
func (c *pluginGalleryServiceClient) DeleteScreenshot(ctx context.Context, req *connect.Request[v1.DeleteScreenshotRequest]) (*connect.Response[v1.DeleteScreenshotResponse], error) {
return c.deleteScreenshot.CallUnary(ctx, req)
}
// ReorderScreenshots calls orchestrator.v1.PluginGalleryService.ReorderScreenshots.
func (c *pluginGalleryServiceClient) ReorderScreenshots(ctx context.Context, req *connect.Request[v1.ReorderScreenshotsRequest]) (*connect.Response[v1.ReorderScreenshotsResponse], error) {
return c.reorderScreenshots.CallUnary(ctx, req)
}
// SetFeaturedScreenshot calls orchestrator.v1.PluginGalleryService.SetFeaturedScreenshot.
func (c *pluginGalleryServiceClient) SetFeaturedScreenshot(ctx context.Context, req *connect.Request[v1.SetFeaturedScreenshotRequest]) (*connect.Response[v1.SetFeaturedScreenshotResponse], error) {
return c.setFeaturedScreenshot.CallUnary(ctx, req)
}
// PluginGalleryServiceHandler is an implementation of the orchestrator.v1.PluginGalleryService
// service.
type PluginGalleryServiceHandler interface {
ListScreenshots(context.Context, *connect.Request[v1.ListScreenshotsRequest]) (*connect.Response[v1.ListScreenshotsResponse], error)
UploadScreenshot(context.Context, *connect.Request[v1.UploadScreenshotRequest]) (*connect.Response[v1.UploadScreenshotResponse], error)
DeleteScreenshot(context.Context, *connect.Request[v1.DeleteScreenshotRequest]) (*connect.Response[v1.DeleteScreenshotResponse], error)
ReorderScreenshots(context.Context, *connect.Request[v1.ReorderScreenshotsRequest]) (*connect.Response[v1.ReorderScreenshotsResponse], error)
SetFeaturedScreenshot(context.Context, *connect.Request[v1.SetFeaturedScreenshotRequest]) (*connect.Response[v1.SetFeaturedScreenshotResponse], error)
}
// NewPluginGalleryServiceHandler builds an HTTP handler from the service implementation. It returns
// the path on which to mount the handler and the handler itself.
//
// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf
// and JSON codecs. They also support gzip compression.
func NewPluginGalleryServiceHandler(svc PluginGalleryServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) {
pluginGalleryServiceMethods := v1.File_orchestrator_v1_plugin_registry_proto.Services().ByName("PluginGalleryService").Methods()
pluginGalleryServiceListScreenshotsHandler := connect.NewUnaryHandler(
PluginGalleryServiceListScreenshotsProcedure,
svc.ListScreenshots,
connect.WithSchema(pluginGalleryServiceMethods.ByName("ListScreenshots")),
connect.WithHandlerOptions(opts...),
)
pluginGalleryServiceUploadScreenshotHandler := connect.NewUnaryHandler(
PluginGalleryServiceUploadScreenshotProcedure,
svc.UploadScreenshot,
connect.WithSchema(pluginGalleryServiceMethods.ByName("UploadScreenshot")),
connect.WithHandlerOptions(opts...),
)
pluginGalleryServiceDeleteScreenshotHandler := connect.NewUnaryHandler(
PluginGalleryServiceDeleteScreenshotProcedure,
svc.DeleteScreenshot,
connect.WithSchema(pluginGalleryServiceMethods.ByName("DeleteScreenshot")),
connect.WithHandlerOptions(opts...),
)
pluginGalleryServiceReorderScreenshotsHandler := connect.NewUnaryHandler(
PluginGalleryServiceReorderScreenshotsProcedure,
svc.ReorderScreenshots,
connect.WithSchema(pluginGalleryServiceMethods.ByName("ReorderScreenshots")),
connect.WithHandlerOptions(opts...),
)
pluginGalleryServiceSetFeaturedScreenshotHandler := connect.NewUnaryHandler(
PluginGalleryServiceSetFeaturedScreenshotProcedure,
svc.SetFeaturedScreenshot,
connect.WithSchema(pluginGalleryServiceMethods.ByName("SetFeaturedScreenshot")),
connect.WithHandlerOptions(opts...),
)
return "/orchestrator.v1.PluginGalleryService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case PluginGalleryServiceListScreenshotsProcedure:
pluginGalleryServiceListScreenshotsHandler.ServeHTTP(w, r)
case PluginGalleryServiceUploadScreenshotProcedure:
pluginGalleryServiceUploadScreenshotHandler.ServeHTTP(w, r)
case PluginGalleryServiceDeleteScreenshotProcedure:
pluginGalleryServiceDeleteScreenshotHandler.ServeHTTP(w, r)
case PluginGalleryServiceReorderScreenshotsProcedure:
pluginGalleryServiceReorderScreenshotsHandler.ServeHTTP(w, r)
case PluginGalleryServiceSetFeaturedScreenshotProcedure:
pluginGalleryServiceSetFeaturedScreenshotHandler.ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
})
}
// UnimplementedPluginGalleryServiceHandler returns CodeUnimplemented from all methods.
type UnimplementedPluginGalleryServiceHandler struct{}
func (UnimplementedPluginGalleryServiceHandler) ListScreenshots(context.Context, *connect.Request[v1.ListScreenshotsRequest]) (*connect.Response[v1.ListScreenshotsResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("orchestrator.v1.PluginGalleryService.ListScreenshots is not implemented"))
}
func (UnimplementedPluginGalleryServiceHandler) UploadScreenshot(context.Context, *connect.Request[v1.UploadScreenshotRequest]) (*connect.Response[v1.UploadScreenshotResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("orchestrator.v1.PluginGalleryService.UploadScreenshot is not implemented"))
}
func (UnimplementedPluginGalleryServiceHandler) DeleteScreenshot(context.Context, *connect.Request[v1.DeleteScreenshotRequest]) (*connect.Response[v1.DeleteScreenshotResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("orchestrator.v1.PluginGalleryService.DeleteScreenshot is not implemented"))
}
func (UnimplementedPluginGalleryServiceHandler) ReorderScreenshots(context.Context, *connect.Request[v1.ReorderScreenshotsRequest]) (*connect.Response[v1.ReorderScreenshotsResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("orchestrator.v1.PluginGalleryService.ReorderScreenshots is not implemented"))
}
func (UnimplementedPluginGalleryServiceHandler) SetFeaturedScreenshot(context.Context, *connect.Request[v1.SetFeaturedScreenshotRequest]) (*connect.Response[v1.SetFeaturedScreenshotResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("orchestrator.v1.PluginGalleryService.SetFeaturedScreenshot is not implemented"))
}
// PluginReviewServiceClient is a client for the orchestrator.v1.PluginReviewService service.
type PluginReviewServiceClient interface {
ListReviews(context.Context, *connect.Request[v1.ListReviewsRequest]) (*connect.Response[v1.ListReviewsResponse], error)
GetMyReview(context.Context, *connect.Request[v1.GetMyReviewRequest]) (*connect.Response[v1.GetMyReviewResponse], error)
UpsertReview(context.Context, *connect.Request[v1.UpsertReviewRequest]) (*connect.Response[v1.UpsertReviewResponse], error)
DeleteReview(context.Context, *connect.Request[v1.DeleteReviewRequest]) (*connect.Response[v1.DeleteReviewResponse], error)
ReplyToReview(context.Context, *connect.Request[v1.ReplyToReviewRequest]) (*connect.Response[v1.ReplyToReviewResponse], error)
DeleteReply(context.Context, *connect.Request[v1.DeleteReplyRequest]) (*connect.Response[v1.DeleteReplyResponse], error)
FlagReview(context.Context, *connect.Request[v1.FlagReviewRequest]) (*connect.Response[v1.FlagReviewResponse], error)
}
// NewPluginReviewServiceClient constructs a client for the orchestrator.v1.PluginReviewService
// service. By default, it uses the Connect protocol with the binary Protobuf Codec, asks for
// gzipped responses, and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply
// the connect.WithGRPC() or connect.WithGRPCWeb() options.
//
// The URL supplied here should be the base URL for the Connect or gRPC server (for example,
// http://api.acme.com or https://acme.com/grpc).
func NewPluginReviewServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) PluginReviewServiceClient {
baseURL = strings.TrimRight(baseURL, "/")
pluginReviewServiceMethods := v1.File_orchestrator_v1_plugin_registry_proto.Services().ByName("PluginReviewService").Methods()
return &pluginReviewServiceClient{
listReviews: connect.NewClient[v1.ListReviewsRequest, v1.ListReviewsResponse](
httpClient,
baseURL+PluginReviewServiceListReviewsProcedure,
connect.WithSchema(pluginReviewServiceMethods.ByName("ListReviews")),
connect.WithClientOptions(opts...),
),
getMyReview: connect.NewClient[v1.GetMyReviewRequest, v1.GetMyReviewResponse](
httpClient,
baseURL+PluginReviewServiceGetMyReviewProcedure,
connect.WithSchema(pluginReviewServiceMethods.ByName("GetMyReview")),
connect.WithClientOptions(opts...),
),
upsertReview: connect.NewClient[v1.UpsertReviewRequest, v1.UpsertReviewResponse](
httpClient,
baseURL+PluginReviewServiceUpsertReviewProcedure,
connect.WithSchema(pluginReviewServiceMethods.ByName("UpsertReview")),
connect.WithClientOptions(opts...),
),
deleteReview: connect.NewClient[v1.DeleteReviewRequest, v1.DeleteReviewResponse](
httpClient,
baseURL+PluginReviewServiceDeleteReviewProcedure,
connect.WithSchema(pluginReviewServiceMethods.ByName("DeleteReview")),
connect.WithClientOptions(opts...),
),
replyToReview: connect.NewClient[v1.ReplyToReviewRequest, v1.ReplyToReviewResponse](
httpClient,
baseURL+PluginReviewServiceReplyToReviewProcedure,
connect.WithSchema(pluginReviewServiceMethods.ByName("ReplyToReview")),
connect.WithClientOptions(opts...),
),
deleteReply: connect.NewClient[v1.DeleteReplyRequest, v1.DeleteReplyResponse](
httpClient,
baseURL+PluginReviewServiceDeleteReplyProcedure,
connect.WithSchema(pluginReviewServiceMethods.ByName("DeleteReply")),
connect.WithClientOptions(opts...),
),
flagReview: connect.NewClient[v1.FlagReviewRequest, v1.FlagReviewResponse](
httpClient,
baseURL+PluginReviewServiceFlagReviewProcedure,
connect.WithSchema(pluginReviewServiceMethods.ByName("FlagReview")),
connect.WithClientOptions(opts...),
),
}
}
// pluginReviewServiceClient implements PluginReviewServiceClient.
type pluginReviewServiceClient struct {
listReviews *connect.Client[v1.ListReviewsRequest, v1.ListReviewsResponse]
getMyReview *connect.Client[v1.GetMyReviewRequest, v1.GetMyReviewResponse]
upsertReview *connect.Client[v1.UpsertReviewRequest, v1.UpsertReviewResponse]
deleteReview *connect.Client[v1.DeleteReviewRequest, v1.DeleteReviewResponse]
replyToReview *connect.Client[v1.ReplyToReviewRequest, v1.ReplyToReviewResponse]
deleteReply *connect.Client[v1.DeleteReplyRequest, v1.DeleteReplyResponse]
flagReview *connect.Client[v1.FlagReviewRequest, v1.FlagReviewResponse]
}
// ListReviews calls orchestrator.v1.PluginReviewService.ListReviews.
func (c *pluginReviewServiceClient) ListReviews(ctx context.Context, req *connect.Request[v1.ListReviewsRequest]) (*connect.Response[v1.ListReviewsResponse], error) {
return c.listReviews.CallUnary(ctx, req)
}
// GetMyReview calls orchestrator.v1.PluginReviewService.GetMyReview.
func (c *pluginReviewServiceClient) GetMyReview(ctx context.Context, req *connect.Request[v1.GetMyReviewRequest]) (*connect.Response[v1.GetMyReviewResponse], error) {
return c.getMyReview.CallUnary(ctx, req)
}
// UpsertReview calls orchestrator.v1.PluginReviewService.UpsertReview.
func (c *pluginReviewServiceClient) UpsertReview(ctx context.Context, req *connect.Request[v1.UpsertReviewRequest]) (*connect.Response[v1.UpsertReviewResponse], error) {
return c.upsertReview.CallUnary(ctx, req)
}
// DeleteReview calls orchestrator.v1.PluginReviewService.DeleteReview.
func (c *pluginReviewServiceClient) DeleteReview(ctx context.Context, req *connect.Request[v1.DeleteReviewRequest]) (*connect.Response[v1.DeleteReviewResponse], error) {
return c.deleteReview.CallUnary(ctx, req)
}
// ReplyToReview calls orchestrator.v1.PluginReviewService.ReplyToReview.
func (c *pluginReviewServiceClient) ReplyToReview(ctx context.Context, req *connect.Request[v1.ReplyToReviewRequest]) (*connect.Response[v1.ReplyToReviewResponse], error) {
return c.replyToReview.CallUnary(ctx, req)
}
// DeleteReply calls orchestrator.v1.PluginReviewService.DeleteReply.
func (c *pluginReviewServiceClient) DeleteReply(ctx context.Context, req *connect.Request[v1.DeleteReplyRequest]) (*connect.Response[v1.DeleteReplyResponse], error) {
return c.deleteReply.CallUnary(ctx, req)
}
// FlagReview calls orchestrator.v1.PluginReviewService.FlagReview.
func (c *pluginReviewServiceClient) FlagReview(ctx context.Context, req *connect.Request[v1.FlagReviewRequest]) (*connect.Response[v1.FlagReviewResponse], error) {
return c.flagReview.CallUnary(ctx, req)
}
// PluginReviewServiceHandler is an implementation of the orchestrator.v1.PluginReviewService
// service.
type PluginReviewServiceHandler interface {
ListReviews(context.Context, *connect.Request[v1.ListReviewsRequest]) (*connect.Response[v1.ListReviewsResponse], error)
GetMyReview(context.Context, *connect.Request[v1.GetMyReviewRequest]) (*connect.Response[v1.GetMyReviewResponse], error)
UpsertReview(context.Context, *connect.Request[v1.UpsertReviewRequest]) (*connect.Response[v1.UpsertReviewResponse], error)
DeleteReview(context.Context, *connect.Request[v1.DeleteReviewRequest]) (*connect.Response[v1.DeleteReviewResponse], error)
ReplyToReview(context.Context, *connect.Request[v1.ReplyToReviewRequest]) (*connect.Response[v1.ReplyToReviewResponse], error)
DeleteReply(context.Context, *connect.Request[v1.DeleteReplyRequest]) (*connect.Response[v1.DeleteReplyResponse], error)
FlagReview(context.Context, *connect.Request[v1.FlagReviewRequest]) (*connect.Response[v1.FlagReviewResponse], error)
}
// NewPluginReviewServiceHandler builds an HTTP handler from the service implementation. It returns
// the path on which to mount the handler and the handler itself.
//
// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf
// and JSON codecs. They also support gzip compression.
func NewPluginReviewServiceHandler(svc PluginReviewServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) {
pluginReviewServiceMethods := v1.File_orchestrator_v1_plugin_registry_proto.Services().ByName("PluginReviewService").Methods()
pluginReviewServiceListReviewsHandler := connect.NewUnaryHandler(
PluginReviewServiceListReviewsProcedure,
svc.ListReviews,
connect.WithSchema(pluginReviewServiceMethods.ByName("ListReviews")),
connect.WithHandlerOptions(opts...),
)
pluginReviewServiceGetMyReviewHandler := connect.NewUnaryHandler(
PluginReviewServiceGetMyReviewProcedure,
svc.GetMyReview,
connect.WithSchema(pluginReviewServiceMethods.ByName("GetMyReview")),
connect.WithHandlerOptions(opts...),
)
pluginReviewServiceUpsertReviewHandler := connect.NewUnaryHandler(
PluginReviewServiceUpsertReviewProcedure,
svc.UpsertReview,
connect.WithSchema(pluginReviewServiceMethods.ByName("UpsertReview")),
connect.WithHandlerOptions(opts...),
)
pluginReviewServiceDeleteReviewHandler := connect.NewUnaryHandler(
PluginReviewServiceDeleteReviewProcedure,
svc.DeleteReview,
connect.WithSchema(pluginReviewServiceMethods.ByName("DeleteReview")),
connect.WithHandlerOptions(opts...),
)
pluginReviewServiceReplyToReviewHandler := connect.NewUnaryHandler(
PluginReviewServiceReplyToReviewProcedure,
svc.ReplyToReview,
connect.WithSchema(pluginReviewServiceMethods.ByName("ReplyToReview")),
connect.WithHandlerOptions(opts...),
)
pluginReviewServiceDeleteReplyHandler := connect.NewUnaryHandler(
PluginReviewServiceDeleteReplyProcedure,
svc.DeleteReply,
connect.WithSchema(pluginReviewServiceMethods.ByName("DeleteReply")),
connect.WithHandlerOptions(opts...),
)
pluginReviewServiceFlagReviewHandler := connect.NewUnaryHandler(
PluginReviewServiceFlagReviewProcedure,
svc.FlagReview,
connect.WithSchema(pluginReviewServiceMethods.ByName("FlagReview")),
connect.WithHandlerOptions(opts...),
)
return "/orchestrator.v1.PluginReviewService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case PluginReviewServiceListReviewsProcedure:
pluginReviewServiceListReviewsHandler.ServeHTTP(w, r)
case PluginReviewServiceGetMyReviewProcedure:
pluginReviewServiceGetMyReviewHandler.ServeHTTP(w, r)
case PluginReviewServiceUpsertReviewProcedure:
pluginReviewServiceUpsertReviewHandler.ServeHTTP(w, r)
case PluginReviewServiceDeleteReviewProcedure:
pluginReviewServiceDeleteReviewHandler.ServeHTTP(w, r)
case PluginReviewServiceReplyToReviewProcedure:
pluginReviewServiceReplyToReviewHandler.ServeHTTP(w, r)
case PluginReviewServiceDeleteReplyProcedure:
pluginReviewServiceDeleteReplyHandler.ServeHTTP(w, r)
case PluginReviewServiceFlagReviewProcedure:
pluginReviewServiceFlagReviewHandler.ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
})
}
// UnimplementedPluginReviewServiceHandler returns CodeUnimplemented from all methods.
type UnimplementedPluginReviewServiceHandler struct{}
func (UnimplementedPluginReviewServiceHandler) ListReviews(context.Context, *connect.Request[v1.ListReviewsRequest]) (*connect.Response[v1.ListReviewsResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("orchestrator.v1.PluginReviewService.ListReviews is not implemented"))
}
func (UnimplementedPluginReviewServiceHandler) GetMyReview(context.Context, *connect.Request[v1.GetMyReviewRequest]) (*connect.Response[v1.GetMyReviewResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("orchestrator.v1.PluginReviewService.GetMyReview is not implemented"))
}
func (UnimplementedPluginReviewServiceHandler) UpsertReview(context.Context, *connect.Request[v1.UpsertReviewRequest]) (*connect.Response[v1.UpsertReviewResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("orchestrator.v1.PluginReviewService.UpsertReview is not implemented"))
}
func (UnimplementedPluginReviewServiceHandler) DeleteReview(context.Context, *connect.Request[v1.DeleteReviewRequest]) (*connect.Response[v1.DeleteReviewResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("orchestrator.v1.PluginReviewService.DeleteReview is not implemented"))
}
func (UnimplementedPluginReviewServiceHandler) ReplyToReview(context.Context, *connect.Request[v1.ReplyToReviewRequest]) (*connect.Response[v1.ReplyToReviewResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("orchestrator.v1.PluginReviewService.ReplyToReview is not implemented"))
}
func (UnimplementedPluginReviewServiceHandler) DeleteReply(context.Context, *connect.Request[v1.DeleteReplyRequest]) (*connect.Response[v1.DeleteReplyResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("orchestrator.v1.PluginReviewService.DeleteReply is not implemented"))
}
func (UnimplementedPluginReviewServiceHandler) FlagReview(context.Context, *connect.Request[v1.FlagReviewRequest]) (*connect.Response[v1.FlagReviewResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("orchestrator.v1.PluginReviewService.FlagReview is not implemented"))
}
// PluginPublishServiceClient is a client for the orchestrator.v1.PluginPublishService service. // PluginPublishServiceClient is a client for the orchestrator.v1.PluginPublishService service.
type PluginPublishServiceClient interface { type PluginPublishServiceClient interface {
PublishVersion(context.Context, *connect.Request[v1.PublishVersionRequest]) (*connect.Response[v1.PublishVersionResponse], error) PublishVersion(context.Context, *connect.Request[v1.PublishVersionRequest]) (*connect.Response[v1.PublishVersionResponse], error)

File diff suppressed because it is too large Load Diff

View File

@ -41,9 +41,9 @@ type BuildOptions struct {
// BuildResult summarizes a produced artifact for the CLI summary table. // BuildResult summarizes a produced artifact for the CLI summary table.
type BuildResult struct { type BuildResult struct {
OutputPath string OutputPath string
Name string Name string
Version string Version string
// Codeless marks a declarative artifact (no plugin.wasm, WO-WZ-020). // Codeless marks a declarative artifact (no plugin.wasm, WO-WZ-020).
Codeless bool Codeless bool
WasmBytes int64 WasmBytes int64
@ -57,6 +57,11 @@ type BuildResult struct {
Hooks []string Hooks []string
DataDir bool DataDir bool
IncludedDirs []string IncludedDirs []string
// PreviewIncluded/ScreenshotCount report the packed presentation members
// (root preview.png + screenshots/); the registry ingests these into the
// plugin's gallery at publish.
PreviewIncluded bool
ScreenshotCount int
} }
// Build compiles the plugin in opts.Dir to wasm, extracts its manifest, and // Build compiles the plugin in opts.Dir to wasm, extracts its manifest, and
@ -182,6 +187,14 @@ func Build(ctx context.Context, opts BuildOptions) (*BuildResult, error) {
includedDirs = append(includedDirs, o.artifact) includedDirs = append(includedDirs, o.artifact)
} }
} }
presEntries, previewIncluded, screenshotCount, err := presentationEntries(dir)
if err != nil {
return nil, err
}
entries = append(entries, presEntries...)
if screenshotCount > 0 {
includedDirs = append(includedDirs, dirScreenshots)
}
// 6. Pack. // 6. Pack.
outPath := opts.Output outPath := opts.Output
@ -200,20 +213,22 @@ func Build(ctx context.Context, opts BuildOptions) (*BuildResult, error) {
wasmInfo, _ := os.Stat(wasmPath) wasmInfo, _ := os.Stat(wasmPath)
return &BuildResult{ return &BuildResult{
OutputPath: outPath, OutputPath: outPath,
Name: manifest.GetName(), Name: manifest.GetName(),
Version: manifest.GetVersion(), Version: manifest.GetVersion(),
WasmBytes: wasmInfo.Size(), WasmBytes: wasmInfo.Size(),
ManifestBytes: int64(len(manifestBytes)), ManifestBytes: int64(len(manifestBytes)),
ArtifactBytes: artInfo.Size(), ArtifactBytes: artInfo.Size(),
UncompBytes: uncomp, UncompBytes: uncomp,
BlockCount: len(manifest.GetBlocks()), BlockCount: len(manifest.GetBlocks()),
TemplateCount: len(manifest.GetTemplateKeys()), TemplateCount: len(manifest.GetTemplateKeys()),
AdminPages: len(manifest.GetAdminPages()), AdminPages: len(manifest.GetAdminPages()),
JobTypes: len(manifest.GetJobTypes()), JobTypes: len(manifest.GetJobTypes()),
Hooks: hooksPresent(manifest), Hooks: hooksPresent(manifest),
DataDir: manifest.GetDataDir(), DataDir: manifest.GetDataDir(),
IncludedDirs: includedDirs, IncludedDirs: includedDirs,
PreviewIncluded: previewIncluded,
ScreenshotCount: screenshotCount,
}, nil }, nil
} }

View File

@ -34,10 +34,10 @@ const manifestYAMLName = "manifest.yaml"
// manifestYAML mirrors the declarative PluginManifest fields a codeless // manifestYAML mirrors the declarative PluginManifest fields a codeless
// plugin can set. File-valued keys are paths relative to the repo root. // plugin can set. File-valued keys are paths relative to the repo root.
type manifestYAML struct { type manifestYAML struct {
ThemePresets string `yaml:"theme_presets"` // JSON file ThemePresets string `yaml:"theme_presets"` // JSON file
BundledFonts string `yaml:"bundled_fonts"` // JSON file BundledFonts string `yaml:"bundled_fonts"` // JSON file
SettingsSchema string `yaml:"settings_schema"` // JSON file SettingsSchema string `yaml:"settings_schema"` // JSON file
MasterPages string `yaml:"master_pages"` // JSON file ([]masterPageJSON) MasterPages string `yaml:"master_pages"` // JSON file ([]masterPageJSON)
RequiredIconPacks []string `yaml:"required_icon_packs"` RequiredIconPacks []string `yaml:"required_icon_packs"`
SystemTemplates []struct { SystemTemplates []struct {
Key string `yaml:"key"` Key string `yaml:"key"`
@ -240,6 +240,14 @@ func BuildCodeless(_ context.Context, opts BuildOptions) (*BuildResult, error) {
includedDirs = append(includedDirs, o.artifact) includedDirs = append(includedDirs, o.artifact)
} }
} }
presEntries, previewIncluded, screenshotCount, err := presentationEntries(dir)
if err != nil {
return nil, err
}
entries = append(entries, presEntries...)
if screenshotCount > 0 {
includedDirs = append(includedDirs, dirScreenshots)
}
outPath := opts.Output outPath := opts.Output
if outPath == "" { if outPath == "" {
@ -255,15 +263,17 @@ func BuildCodeless(_ context.Context, opts BuildOptions) (*BuildResult, error) {
} }
return &BuildResult{ return &BuildResult{
OutputPath: outPath, OutputPath: outPath,
Name: manifest.GetName(), Name: manifest.GetName(),
Version: manifest.GetVersion(), Version: manifest.GetVersion(),
Codeless: true, Codeless: true,
ManifestBytes: int64(len(manifestBytes)), ManifestBytes: int64(len(manifestBytes)),
ArtifactBytes: artInfo.Size(), ArtifactBytes: artInfo.Size(),
UncompBytes: uncomp, UncompBytes: uncomp,
BlockCount: blockCount, BlockCount: blockCount,
IncludedDirs: includedDirs, IncludedDirs: includedDirs,
PreviewIncluded: previewIncluded,
ScreenshotCount: screenshotCount,
}, nil }, nil
} }

121
internal/bnp/screenshots.go Normal file
View File

@ -0,0 +1,121 @@
package bnp
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// Presentation members: a root preview.png plus an optional screenshots/ dir.
// Neither ships behavior — the registry ingests them at publish into the
// plugin's managed gallery (spec 2026-07-05-registry-presentation-reviews).
// The .so-era source archives carried preview.png implicitly; the .bnp
// packers dropped it, which silently wiped previews on republish — these
// entries restore that and add the multi-screenshot surface.
const (
filePreview = "preview.png"
dirScreenshots = "screenshots"
// maxScreenshotBytes caps one presentation image (registry gallery cap).
maxScreenshotBytes = 8 << 20
// maxScreenshotFiles caps the screenshots/ dir (registry gallery cap).
maxScreenshotFiles = 12
)
// screenshotExtOK reports whether a filename carries an accepted gallery
// image extension.
func screenshotExtOK(name string) bool {
switch strings.ToLower(filepath.Ext(name)) {
case ".png", ".jpg", ".jpeg", ".webp":
return true
}
return false
}
// validateScreenshotsDir checks the optional screenshots/ dir: accepted
// extensions only, ≤ maxScreenshotBytes per file, ≤ maxScreenshotFiles total,
// no subdirectories or irregular files. Returns the file count (0 when the
// dir is absent).
func validateScreenshotsDir(dir string) (int, error) {
root := filepath.Join(dir, dirScreenshots)
info, err := os.Stat(root)
if os.IsNotExist(err) {
return 0, nil
}
if err != nil {
return 0, fmt.Errorf("stat %s/: %w", dirScreenshots, err)
}
if !info.IsDir() {
return 0, fmt.Errorf("%s exists but is not a directory", dirScreenshots)
}
entries, err := os.ReadDir(root)
if err != nil {
return 0, fmt.Errorf("read %s/: %w", dirScreenshots, err)
}
count := 0
for _, e := range entries {
if e.IsDir() {
return 0, fmt.Errorf("%s/%s: subdirectories are not allowed in screenshots/", dirScreenshots, e.Name())
}
fi, err := e.Info()
if err != nil {
return 0, fmt.Errorf("%s/%s: %w", dirScreenshots, e.Name(), err)
}
if !fi.Mode().IsRegular() {
return 0, fmt.Errorf("%s/%s: not a regular file", dirScreenshots, e.Name())
}
if !screenshotExtOK(e.Name()) {
return 0, fmt.Errorf("%s/%s: unsupported image type (allowed: .png .jpg .jpeg .webp)", dirScreenshots, e.Name())
}
if fi.Size() > maxScreenshotBytes {
return 0, fmt.Errorf("%s/%s: %d bytes exceeds the %d MB per-image cap", dirScreenshots, e.Name(), fi.Size(), maxScreenshotBytes>>20)
}
count++
}
if count > maxScreenshotFiles {
return 0, fmt.Errorf("%s/ holds %d images; the gallery cap is %d", dirScreenshots, count, maxScreenshotFiles)
}
return count, nil
}
// presentationEntries validates and returns pack entries for the optional
// root preview.png and screenshots/ dir. Both are optional; absence is not an
// error.
func presentationEntries(dir string) (entries []packEntry, previewIncluded bool, screenshotCount int, err error) {
previewPath := filepath.Join(dir, filePreview)
if fi, statErr := os.Stat(previewPath); statErr == nil {
if !fi.Mode().IsRegular() {
return nil, false, 0, fmt.Errorf("%s is not a regular file", filePreview)
}
if fi.Size() > maxScreenshotBytes {
return nil, false, 0, fmt.Errorf("%s: %d bytes exceeds the %d MB per-image cap", filePreview, fi.Size(), maxScreenshotBytes>>20)
}
entries = append(entries, packEntry{ArtifactPath: filePreview, Source: previewPath})
previewIncluded = true
}
screenshotCount, err = validateScreenshotsDir(dir)
if err != nil {
return nil, false, 0, err
}
if screenshotCount > 0 {
root := filepath.Join(dir, dirScreenshots)
files, readErr := os.ReadDir(root)
if readErr != nil {
return nil, false, 0, fmt.Errorf("read %s/: %w", dirScreenshots, readErr)
}
names := make([]string, 0, len(files))
for _, f := range files {
names = append(names, f.Name())
}
sort.Strings(names)
for _, name := range names {
entries = append(entries, packEntry{
ArtifactPath: dirScreenshots + "/" + name,
Source: filepath.Join(root, name),
})
}
}
return entries, previewIncluded, screenshotCount, nil
}

View File

@ -0,0 +1,152 @@
package bnp
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
// presentationFixture extends the codeless fixture with a root preview.png
// and a screenshots/ dir.
func presentationFixture(t *testing.T) string {
t.Helper()
dir := codelessFixture(t)
writeFixture(t, dir, map[string]string{
"preview.png": "not-really-png-but-bytes",
"screenshots/01-home.png": "home-bytes",
"screenshots/02-blog.jpg": "blog-bytes",
"screenshots/03-page.webp": "page-bytes",
})
return dir
}
func TestCodelessBuildPacksPresentationMembers(t *testing.T) {
dir := presentationFixture(t)
out := filepath.Join(t.TempDir(), "fixture-theme-0.1.0.bnp")
res, err := BuildCodeless(context.Background(), BuildOptions{Dir: dir, Output: out})
if err != nil {
t.Fatalf("BuildCodeless: %v", err)
}
if !res.PreviewIncluded || res.ScreenshotCount != 3 {
t.Errorf("result preview=%t screenshots=%d; want true/3", res.PreviewIncluded, res.ScreenshotCount)
}
found := false
for _, d := range res.IncludedDirs {
if d == dirScreenshots {
found = true
}
}
if !found {
t.Errorf("IncludedDirs %v missing %q", res.IncludedDirs, dirScreenshots)
}
v, err := Verify(out)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if !v.HasPreview || v.ScreenshotCount != 3 {
t.Errorf("verify preview=%t screenshots=%d; want true/3", v.HasPreview, v.ScreenshotCount)
}
}
func TestCodelessBuildWithoutPresentationMembers(t *testing.T) {
dir := codelessFixture(t)
out := filepath.Join(t.TempDir(), "fixture-theme-0.1.0.bnp")
res, err := BuildCodeless(context.Background(), BuildOptions{Dir: dir, Output: out})
if err != nil {
t.Fatalf("BuildCodeless: %v", err)
}
if res.PreviewIncluded || res.ScreenshotCount != 0 {
t.Errorf("result preview=%t screenshots=%d; want false/0", res.PreviewIncluded, res.ScreenshotCount)
}
v, err := Verify(out)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if v.HasPreview || v.ScreenshotCount != 0 {
t.Errorf("verify preview=%t screenshots=%d; want false/0", v.HasPreview, v.ScreenshotCount)
}
}
func TestValidateScreenshotsDirRejections(t *testing.T) {
build := func(t *testing.T, mutate func(dir string)) error {
t.Helper()
dir := codelessFixture(t)
mutate(dir)
out := filepath.Join(t.TempDir(), "out.bnp")
_, err := BuildCodeless(context.Background(), BuildOptions{Dir: dir, Output: out})
return err
}
t.Run("bad extension", func(t *testing.T) {
err := build(t, func(dir string) {
writeFixture(t, dir, map[string]string{"screenshots/readme.txt": "nope"})
})
if err == nil || !strings.Contains(err.Error(), "unsupported image type") {
t.Fatalf("err = %v; want unsupported image type", err)
}
})
t.Run("subdirectory", func(t *testing.T) {
err := build(t, func(dir string) {
writeFixture(t, dir, map[string]string{"screenshots/sub/x.png": "nope"})
})
if err == nil || !strings.Contains(err.Error(), "subdirectories are not allowed") {
t.Fatalf("err = %v; want subdirectory rejection", err)
}
})
t.Run("too many", func(t *testing.T) {
err := build(t, func(dir string) {
files := map[string]string{}
for i := range maxScreenshotFiles + 1 {
files[fmt.Sprintf("screenshots/%02d.png", i)] = "x"
}
writeFixture(t, dir, files)
})
if err == nil || !strings.Contains(err.Error(), "gallery cap") {
t.Fatalf("err = %v; want gallery cap rejection", err)
}
})
t.Run("oversized image", func(t *testing.T) {
err := build(t, func(dir string) {
big := filepath.Join(dir, dirScreenshots, "big.png")
if mkErr := os.MkdirAll(filepath.Dir(big), 0o755); mkErr != nil {
t.Fatal(mkErr)
}
f, cErr := os.Create(big)
if cErr != nil {
t.Fatal(cErr)
}
if tErr := f.Truncate(maxScreenshotBytes + 1); tErr != nil {
t.Fatal(tErr)
}
_ = f.Close()
})
if err == nil || !strings.Contains(err.Error(), "per-image cap") {
t.Fatalf("err = %v; want per-image cap rejection", err)
}
})
t.Run("oversized preview", func(t *testing.T) {
err := build(t, func(dir string) {
big := filepath.Join(dir, filePreview)
f, cErr := os.Create(big)
if cErr != nil {
t.Fatal(cErr)
}
if tErr := f.Truncate(maxScreenshotBytes + 1); tErr != nil {
t.Fatal(tErr)
}
_ = f.Close()
})
if err == nil || !strings.Contains(err.Error(), "per-image cap") {
t.Fatalf("err = %v; want per-image cap rejection", err)
}
})
}

View File

@ -47,6 +47,10 @@ type VerifyResult struct {
HasBlocks bool HasBlocks bool
HasTemplates bool HasTemplates bool
HasSeed bool HasSeed bool
// HasPreview/ScreenshotCount report the presentation members the registry
// ingests into the plugin gallery at publish.
HasPreview bool
ScreenshotCount int
} }
// Verify extracts bnpPath into a temp dir and applies the reader's checks: // Verify extracts bnpPath into a temp dir and applies the reader's checks:
@ -119,22 +123,40 @@ func Verify(bnpPath string) (*VerifyResult, error) {
} }
return &VerifyResult{ return &VerifyResult{
Name: name, Name: name,
Version: manifest.GetVersion(), Version: manifest.GetVersion(),
ABIVersion: manifest.GetAbiVersion(), ABIVersion: manifest.GetAbiVersion(),
Codeless: manifest.GetCodeless(), Codeless: manifest.GetCodeless(),
DataDir: manifest.GetDataDir(), DataDir: manifest.GetDataDir(),
BlockCount: len(manifest.GetBlocks()), BlockCount: len(manifest.GetBlocks()),
HasMigrations: dirExists(filepath.Join(destDir, "migrations")), HasMigrations: dirExists(filepath.Join(destDir, "migrations")),
HasSchemas: dirExists(filepath.Join(destDir, "schemas")), HasSchemas: dirExists(filepath.Join(destDir, "schemas")),
HasAssets: dirExists(filepath.Join(destDir, "assets")), HasAssets: dirExists(filepath.Join(destDir, "assets")),
HasWeb: dirExists(filepath.Join(destDir, "web")), HasWeb: dirExists(filepath.Join(destDir, "web")),
HasBlocks: dirExists(filepath.Join(destDir, dirBlocks)), HasBlocks: dirExists(filepath.Join(destDir, dirBlocks)),
HasTemplates: dirExists(filepath.Join(destDir, dirTemplates)), HasTemplates: dirExists(filepath.Join(destDir, dirTemplates)),
HasSeed: dirExists(filepath.Join(destDir, dirSeed)), HasSeed: dirExists(filepath.Join(destDir, dirSeed)),
HasPreview: fileRegular(filepath.Join(destDir, filePreview)),
ScreenshotCount: countScreenshots(filepath.Join(destDir, dirScreenshots)),
}, nil }, nil
} }
// countScreenshots counts accepted image files directly under an extracted
// screenshots/ dir (0 when absent).
func countScreenshots(root string) int {
entries, err := os.ReadDir(root)
if err != nil {
return 0
}
n := 0
for _, e := range entries {
if !e.IsDir() && screenshotExtOK(e.Name()) {
n++
}
}
return n
}
// CodelessHookViolation returns a named error when a codeless manifest // CodelessHookViolation returns a named error when a codeless manifest
// declares any capability that requires guest code. Mirrored by the CMS // declares any capability that requires guest code. Mirrored by the CMS
// reader (lockstep duplication, same as the rest of this file). // reader (lockstep duplication, same as the rest of this file).

View File

@ -11,10 +11,11 @@ import (
type Client struct { type Client struct {
Host string Host string
Token string Token string
Auth orchestratorv1connect.PluginAuthServiceClient Auth orchestratorv1connect.PluginAuthServiceClient
Scope orchestratorv1connect.PluginScopeServiceClient Scope orchestratorv1connect.PluginScopeServiceClient
Reg orchestratorv1connect.PluginRegistryServiceClient Reg orchestratorv1connect.PluginRegistryServiceClient
Pub orchestratorv1connect.PluginPublishServiceClient Pub orchestratorv1connect.PluginPublishServiceClient
Gallery orchestratorv1connect.PluginGalleryServiceClient
} }
func New(host, token string) *Client { func New(host, token string) *Client {
@ -27,6 +28,7 @@ func New(host, token string) *Client {
c.Scope = orchestratorv1connect.NewPluginScopeServiceClient(httpClient, host, opts...) c.Scope = orchestratorv1connect.NewPluginScopeServiceClient(httpClient, host, opts...)
c.Reg = orchestratorv1connect.NewPluginRegistryServiceClient(httpClient, host, opts...) c.Reg = orchestratorv1connect.NewPluginRegistryServiceClient(httpClient, host, opts...)
c.Pub = orchestratorv1connect.NewPluginPublishServiceClient(httpClient, host, opts...) c.Pub = orchestratorv1connect.NewPluginPublishServiceClient(httpClient, host, opts...)
c.Gallery = orchestratorv1connect.NewPluginGalleryServiceClient(httpClient, host, opts...)
return c return c
} }

View File

@ -49,6 +49,36 @@ service PluginModerationService {
rpc ApproveSubmission(ApproveSubmissionRequest) returns (ApproveSubmissionResponse); rpc ApproveSubmission(ApproveSubmissionRequest) returns (ApproveSubmissionResponse);
rpc RejectSubmission(RejectSubmissionRequest) returns (RejectSubmissionResponse); rpc RejectSubmission(RejectSubmissionRequest) returns (RejectSubmissionResponse);
rpc RequestChanges(RequestChangesRequest) returns (RequestChangesResponse); rpc RequestChanges(RequestChangesRequest) returns (RequestChangesResponse);
// Star-review moderation (ADR 0021). Unrelated to the submission queue
// above: these act on user reviews (PluginReviewService), not plugins.
rpc ListFlaggedReviews(ListFlaggedReviewsRequest) returns (ListFlaggedReviewsResponse);
rpc SoftDeleteReview(SoftDeleteReviewRequest) returns (SoftDeleteReviewResponse);
rpc SoftDeleteReply(SoftDeleteReplyRequest) returns (SoftDeleteReplyResponse);
}
// PluginGalleryService manages a plugin's screenshot gallery. Reads are
// public; writes require scope (or owning-account) membership, enforced
// in-handler.
service PluginGalleryService {
rpc ListScreenshots(ListScreenshotsRequest) returns (ListScreenshotsResponse);
rpc UploadScreenshot(UploadScreenshotRequest) returns (UploadScreenshotResponse);
rpc DeleteScreenshot(DeleteScreenshotRequest) returns (DeleteScreenshotResponse);
rpc ReorderScreenshots(ReorderScreenshotsRequest) returns (ReorderScreenshotsResponse);
rpc SetFeaturedScreenshot(SetFeaturedScreenshotRequest) returns (SetFeaturedScreenshotResponse);
}
// PluginReviewService is the star-review surface for PUBLIC plugins
// (ADR 0021): verified-install gating, one review per user, author replies,
// user flags. Reads are public; writes require a logged-in orchestrator user.
service PluginReviewService {
rpc ListReviews(ListReviewsRequest) returns (ListReviewsResponse);
rpc GetMyReview(GetMyReviewRequest) returns (GetMyReviewResponse);
rpc UpsertReview(UpsertReviewRequest) returns (UpsertReviewResponse);
rpc DeleteReview(DeleteReviewRequest) returns (DeleteReviewResponse);
rpc ReplyToReview(ReplyToReviewRequest) returns (ReplyToReviewResponse);
rpc DeleteReply(DeleteReplyRequest) returns (DeleteReplyResponse);
rpc FlagReview(FlagReviewRequest) returns (FlagReviewResponse);
} }
// PluginPublishService is called by the ninja CLI to publish a version. // PluginPublishService is called by the ninja CLI to publish a version.
@ -117,10 +147,32 @@ message Plugin {
string latest_version = 13; string latest_version = 13;
repeated string tags = 14; repeated string tags = 14;
// preview_image_url is the PUBLIC, unsigned, long-lived image URL for this // preview_image_url is the PUBLIC, unsigned, long-lived image URL for this
// plugin's latest non-yanked version preview (a PNG screenshot), usable // plugin's card image, usable directly as an <img src>: the gallery's
// directly as an <img src>. Empty when no preview has been published yet // Featured screenshot when one exists, else the latest non-yanked version's
// the wizard card then degrades to display_name + tags (spec §5.2). // preview.png. Empty when neither exists the wizard card then degrades to
// display_name + tags (spec §5.2).
string preview_image_url = 15; string preview_image_url = 15;
// rating_avg/rating_count are the denormalised star-review aggregate over
// non-deleted reviews (0/0 when unreviewed).
float rating_avg = 16;
int32 rating_count = 17;
// install_count is the number of distinct CMS instances currently recorded
// as installers of this plugin (registry_install_events grain).
int32 install_count = 18;
}
// PluginScreenshot is one image in a plugin's managed gallery. The gallery is
// per-plugin (not per-version); images arrive either from a published
// artifact's screenshots/ dir or from author uploads, and the author orders
// them and marks one Featured (the card image).
message PluginScreenshot {
string id = 1;
// url is the public unsigned image URL (only public plugins serve gallery
// images in v1).
string url = 2;
bool featured = 3;
int32 position = 4;
string content_type = 5;
} }
message Category { message Category {
@ -199,6 +251,8 @@ message GetPluginResponse {
Plugin plugin = 1; Plugin plugin = 1;
repeated Version versions = 2; repeated Version versions = 2;
map<string,string> channels = 3; map<string,string> channels = 3;
// screenshots is the plugin's gallery in display order (Featured included).
repeated PluginScreenshot screenshots = 4;
} }
message ListPluginsRequest { message ListPluginsRequest {
@ -323,8 +377,10 @@ message ResolveInstallResponse {
// --- Instance-authenticated request shapes --- // --- Instance-authenticated request shapes ---
// The instance proves its identity with the per-account X-CMS-Secret header; // The instance proves its identity with the per-account X-CMS-Secret header;
// instance_id selects the instance whose owning account scopes the call. No // instance_id selects the instance whose owning account scopes the call. No
// active_account_id / user JWT is needed. These cover only private plugins // active_account_id / user JWT is needed. Listing covers only private plugins
// public plugins use the anonymous ListPlugins / ResolveInstall. // (public plugins use the anonymous ListPlugins); resolve accepts BOTH the
// instance identity is what lets the registry record the install event, which
// backs public install counts and verified-install review gating (ADR 0021).
message ListPrivatePluginsForInstanceRequest { message ListPrivatePluginsForInstanceRequest {
string instance_id = 1; string instance_id = 1;
@ -334,6 +390,10 @@ message ResolveInstallForInstanceRequest {
string instance_id = 1; string instance_id = 1;
string plugin_name = 2; string plugin_name = 2;
string version_or_channel = 3; string version_or_channel = 3;
// scope_slug selects the plugin's scope. Empty or "private" resolves the
// calling account's private plugin (the original behavior); any other value
// resolves a PUBLIC plugin in that scope (ADR 0021).
string scope_slug = 4;
} }
// --- Review / moderation --- // --- Review / moderation ---
@ -385,6 +445,180 @@ message RequestChangesRequest {
} }
message RequestChangesResponse {} message RequestChangesResponse {}
// --- Gallery service ---
message ListScreenshotsRequest {
string scope_slug = 1;
string plugin_name = 2;
}
message ListScreenshotsResponse {
repeated PluginScreenshot screenshots = 1;
}
message UploadScreenshotRequest {
string plugin_id = 1;
// image is the raw file bytes ( 8 MiB; png/jpg/jpeg/webp).
bytes image = 2;
string filename = 3;
string content_type = 4;
}
message UploadScreenshotResponse {
PluginScreenshot screenshot = 1;
}
message DeleteScreenshotRequest {
string plugin_id = 1;
string screenshot_id = 2;
}
message DeleteScreenshotResponse {}
message ReorderScreenshotsRequest {
string plugin_id = 1;
// screenshot_ids_in_order must name every current gallery image exactly once.
repeated string screenshot_ids_in_order = 2;
}
message ReorderScreenshotsResponse {
repeated PluginScreenshot screenshots = 1;
}
message SetFeaturedScreenshotRequest {
string plugin_id = 1;
string screenshot_id = 2;
}
message SetFeaturedScreenshotResponse {}
// --- Star reviews (ADR 0021) ---
message PluginReview {
string id = 1;
string plugin_id = 2;
string user_id = 3;
string author_display_name = 4;
int32 rating = 5; // 1..5
string title = 6;
string body = 7;
// version_at_review is the plugin version the reviewer's account had
// installed when the review was written (best effort; may be empty).
string version_at_review = 8;
bool edited = 9;
google.protobuf.Timestamp created_at = 10;
google.protobuf.Timestamp updated_at = 11;
PluginReviewReply reply = 12; // unset when no (non-deleted) reply exists
// deleted/deleted_reason are populated only in moderation views; public
// listings exclude soft-deleted reviews entirely.
bool deleted = 13;
string deleted_reason = 14;
}
message PluginReviewReply {
string id = 1;
string author_display_name = 2;
string body = 3;
bool edited = 4;
google.protobuf.Timestamp created_at = 5;
}
message ReviewAggregate {
float rating_avg = 1;
int32 rating_count = 2;
// count1..count5 are the per-star histogram buckets.
int32 count1 = 3;
int32 count2 = 4;
int32 count3 = 5;
int32 count4 = 6;
int32 count5 = 7;
}
message ListReviewsRequest {
string scope_slug = 1;
string plugin_name = 2;
int32 limit = 3; // 0 server default
int32 offset = 4;
}
message ListReviewsResponse {
repeated PluginReview reviews = 1;
ReviewAggregate aggregate = 2;
}
message GetMyReviewRequest {
string plugin_id = 1;
}
message GetMyReviewResponse {
PluginReview review = 1; // unset when the caller has no review
// eligible reports whether the caller may write a review (verified install
// on their account, not a scope member). The form UI keys off this.
bool eligible = 2;
// ineligible_reason is a human-readable explanation when eligible = false
// ("no install on your account", "authors cannot review their own plugin").
string ineligible_reason = 3;
}
message UpsertReviewRequest {
string plugin_id = 1;
int32 rating = 2; // 1..5, required
string title = 3;
string body = 4;
}
message UpsertReviewResponse {
PluginReview review = 1;
}
message DeleteReviewRequest {
string plugin_id = 1;
}
message DeleteReviewResponse {}
message ReplyToReviewRequest {
string review_id = 1;
string body = 2;
}
message ReplyToReviewResponse {
PluginReviewReply reply = 1;
}
message DeleteReplyRequest {
string review_id = 1;
}
message DeleteReplyResponse {}
message FlagReviewRequest {
string review_id = 1;
string reason = 2; // required
}
message FlagReviewResponse {}
// --- Review moderation (superadmin) ---
message FlaggedReview {
PluginReview review = 1;
string scope_slug = 2;
string plugin_name = 3;
int32 flag_count = 4;
// flag_reasons carries the open flags' reasons, newest first.
repeated string flag_reasons = 5;
google.protobuf.Timestamp first_flagged_at = 6;
}
message ListFlaggedReviewsRequest {
int32 limit = 1;
int32 offset = 2;
}
message ListFlaggedReviewsResponse {
repeated FlaggedReview reviews = 1;
}
message SoftDeleteReviewRequest {
string review_id = 1;
string reason = 2; // required audit trail
}
message SoftDeleteReviewResponse {}
message SoftDeleteReplyRequest {
string review_id = 1;
string reason = 2; // required audit trail
}
message SoftDeleteReplyResponse {}
// --- Publish service --- // --- Publish service ---
message PublishVersionRequest { message PublishVersionRequest {