Compare commits

..

No commits in common. "7af42c1c835b2784ac907597e163c6fef3b90632" and "a79aa709c2701a1114148a8d12acfce63a260708" have entirely different histories.

4 changed files with 96 additions and 181 deletions

View File

@ -76,30 +76,14 @@ func newPluginInitCmd() *cobra.Command {
}
}
kind, err := promptKind(scanner)
if err != nil {
return err
}
var cats []string
if kind == "plugin" {
cats, err = promptCategories(ctx, cli, scanner)
if err != nil {
return err
}
}
if _, err := cli.Reg.CreatePlugin(ctx, connect.NewRequest(&v1.CreatePluginRequest{
ScopeSlug: scopeAPISlug(scope),
Name: name,
Description: "",
Kind: kind,
Categories: cats,
ScopeSlug: scopeAPISlug(scope), Name: name, Description: "",
})); err != nil {
return err
}
fmt.Printf("\nCreated %s/%s\n", scope, name)
if err := upsertPluginMod(scope, name, kind, cats); err != nil {
if err := upsertPluginMod(scope, name); err != nil {
return err
}
fmt.Println("plugin.mod updated")
@ -214,56 +198,6 @@ func createScopeInline(ctx context.Context, cli *orchclient.Client, scanner *buf
return slug, nil
}
func promptKind(scanner *bufio.Scanner) (string, error) {
fmt.Println("Kind: 1) plugin 2) theme")
fmt.Print("Select [1]: ")
if !scanner.Scan() {
return "", fmt.Errorf("cancelled")
}
v := strings.TrimSpace(scanner.Text())
switch v {
case "", "1", "plugin":
return "plugin", nil
case "2", "theme":
return "theme", nil
default:
return "", fmt.Errorf("invalid kind: %s", v)
}
}
func promptCategories(ctx context.Context, cli *orchclient.Client, scanner *bufio.Scanner) ([]string, error) {
resp, err := cli.Reg.ListCategories(ctx, connect.NewRequest(&v1.ListCategoriesRequest{}))
if err != nil {
return nil, fmt.Errorf("list categories: %w", err)
}
cats := resp.Msg.Categories
if len(cats) == 0 {
return nil, nil
}
fmt.Println("Categories (comma-separated numbers, or blank to skip):")
for i, c := range cats {
fmt.Printf(" %d. %s — %s\n", i+1, c.Slug, c.DisplayName)
}
fmt.Print("Select: ")
if !scanner.Scan() {
return nil, fmt.Errorf("cancelled")
}
raw := strings.TrimSpace(scanner.Text())
if raw == "" {
return nil, nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
n, err := strconv.Atoi(strings.TrimSpace(p))
if err != nil || n < 1 || n > len(cats) {
return nil, fmt.Errorf("invalid category selection: %s", p)
}
out = append(out, cats[n-1].Slug)
}
return out, nil
}
func newPluginPublishCmd() *cobra.Command {
var channel string
var allowDirty bool
@ -548,7 +482,7 @@ func runCmd(name string, args ...string) error {
return c.Run()
}
func upsertPluginMod(scope, name, kind string, categories []string) error {
func upsertPluginMod(scope, name string) error {
const file = "plugin.mod"
existing, _ := os.ReadFile(file)
mod, _ := core.ParseModFull(existing)
@ -560,8 +494,6 @@ func upsertPluginMod(scope, name, kind string, categories []string) error {
}
mod.Plugin.Scope = scope
mod.Plugin.Name = name
mod.Plugin.Kind = kind
mod.Plugin.Categories = categories
return writeMod(file, mod)
}

View File

@ -5,33 +5,18 @@ import (
"fmt"
"io"
"os/exec"
"strings"
"github.com/klauspost/compress/zstd"
)
// BuildSourceArchive captures the working tree as `tar.zst` bytes.
// BuildSourceArchive runs `git archive --format=tar HEAD` in repoDir and
// compresses the result with zstd. Returns the compressed bytes.
//
// When the working tree is clean it archives HEAD. When it's dirty
// (modified or staged tracked files), it archives a temporary stash
// object so the dirty state is what ships — callers that want
// HEAD-only behaviour should reject dirty trees before calling.
// Untracked files are never included regardless of state.
// Only tracked files at HEAD are included. .gitignored files that were
// never tracked are excluded. Tracked-then-gitignored files are still
// included — callers may warn separately.
func BuildSourceArchive(repoDir string) ([]byte, error) {
stashCmd := exec.Command("git", "stash", "create")
stashCmd.Dir = repoDir
var stashOut, stashErr bytes.Buffer
stashCmd.Stdout = &stashOut
stashCmd.Stderr = &stashErr
if err := stashCmd.Run(); err != nil {
return nil, fmt.Errorf("git stash create: %v: %s", err, stashErr.String())
}
treeish := "HEAD"
if sha := strings.TrimSpace(stashOut.String()); sha != "" {
treeish = sha
}
cmd := exec.Command("git", "archive", "--format=tar", treeish)
cmd := exec.Command("git", "archive", "--format=tar", "HEAD")
cmd.Dir = repoDir
var tarOut, stderr bytes.Buffer
cmd.Stdout = &tarOut

View File

@ -30,48 +30,48 @@ uses; commands assume access via `podman exec blockninja-db psql -U orchestrator
### A1. Init writes plugin.mod with all fields and auto-commits it
- [x] Setup:
- [ ] Setup:
```bash
rm -rf /tmp/uat-a1 && mkdir /tmp/uat-a1 && cd /tmp/uat-a1
git init -q && git commit --allow-empty -qm "initial"
```
- [x] Run: `ninja plugin init --host https://my.localdev.blockninjacms.com --scope @themes --name uat-a1` (answer: kind=plugin, categories=1,2)
- [x] Observe `cat plugin.mod` includes:
- [ ] Run: `ninja plugin init --host https://my.localdev.blockninjacms.com --scope @themes --name uat-a1` (answer: kind=plugin, categories=1,2)
- [ ] Observe `cat plugin.mod` includes:
- `name = "uat-a1"`
- `scope = "@themes"`
- `version = "0.1.0"`
- `kind = "plugin"`
- `categories = ["analytics", "seo"]`
- [x] Observe `git log --oneline` shows a commit whose subject is `Add plugin.mod` at HEAD.
- [x] Observe `git remote -v` outputs nothing (no `ninja` remote).
- [x] Observe `grep -A1 '\[remote' .git/config` finds NO `ninja` block.
- [ ] Observe `git log --oneline` shows a commit whose subject is `Add plugin.mod` at HEAD.
- [ ] Observe `git remote -v` outputs nothing (no `ninja` remote).
- [ ] Observe `grep -A1 '\[remote' .git/config` finds NO `ninja` block.
### A2. Init for a theme writes kind=theme and skips category prompt
- [x] Setup:
- [ ] Setup:
```bash
rm -rf /tmp/uat-a2 && mkdir /tmp/uat-a2 && cd /tmp/uat-a2
git init -q && git commit --allow-empty -qm "initial"
```
- [x] Run init for `@themes/uat-a2`, answer kind=theme. The category prompt MUST NOT appear.
- [x] Observe `plugin.mod` contains `kind = "theme"` and NO `categories =` line.
- [ ] Run init for `@themes/uat-a2`, answer kind=theme. The category prompt MUST NOT appear.
- [ ] Observe `plugin.mod` contains `kind = "theme"` and NO `categories =` line.
### A3. Init when not in a git repo prints a warning, still registers
- [x] Setup:
- [ ] Setup:
```bash
rm -rf /tmp/uat-a3 && mkdir /tmp/uat-a3 && cd /tmp/uat-a3
```
- [x] Run init for `@themes/uat-a3`. STDOUT/STDERR contains a warning mentioning `git init` before publish.
- [x] `plugin.mod` exists on disk.
- [x] No `.git` directory was created.
- [x] Database row exists: `psql -c "SELECT 1 FROM registry_plugins WHERE name='uat-a3';"` returns 1.
- [ ] Run init for `@themes/uat-a3`. STDOUT/STDERR contains a warning mentioning `git init` before publish.
- [ ] `plugin.mod` exists on disk.
- [ ] No `.git` directory was created.
- [ ] Database row exists: `psql -c "SELECT 1 FROM registry_plugins WHERE name='uat-a3';"` returns 1.
### A4. Init twice with same name returns AlreadyExists, leaves repo unchanged
- [x] In a freshly-init'd repo with no plugin.mod, run init twice with the same scope+name.
- [x] Second invocation errors with a message mentioning the plugin already exists.
- [x] `git log` shows exactly ONE `Add plugin.mod` commit (not two).
- [ ] In a freshly-init'd repo with no plugin.mod, run init twice with the same scope+name.
- [ ] Second invocation errors with a message mentioning the plugin already exists.
- [ ] `git log` shows exactly ONE `Add plugin.mod` commit (not two).
---
@ -79,18 +79,18 @@ uses; commands assume access via `podman exec blockninja-db psql -U orchestrator
### B1. Publish posts a tar.zst, no git tag, no git push
- [x] Setup from a working init'd repo (e.g. uat-a1 above).
- [x] Pre-record:
- [ ] Setup from a working init'd repo (e.g. uat-a1 above).
- [ ] Pre-record:
```bash
git tag --list # capture before
git rev-parse HEAD # capture HEAD before
```
- [x] Run `ninja plugin publish --host https://my.localdev.blockninjacms.com`.
- [x] STDOUT contains `Published @themes/uat-a1@0.1.0 (NNN bytes)`.
- [x] After publish, `git tag --list` output is byte-identical to before. (NO `v0.1.0` tag was created.)
- [x] `git rev-parse HEAD` is unchanged.
- [x] `git remote -v` is still empty.
- [x] DB row exists with the new version:
- [ ] Run `ninja plugin publish --host https://my.localdev.blockninjacms.com`.
- [ ] STDOUT contains `Published @themes/uat-a1@0.1.0 (NNN bytes)`.
- [ ] After publish, `git tag --list` output is byte-identical to before. (NO `v0.1.0` tag was created.)
- [ ] `git rev-parse HEAD` is unchanged.
- [ ] `git remote -v` is still empty.
- [ ] DB row exists with the new version:
```sql
SELECT v.version, v.source_archive_key, v.source_archive_size, v.source_archive_sha256
FROM registry_versions v
@ -101,13 +101,13 @@ uses; commands assume access via `podman exec blockninja-db psql -U orchestrator
### B2. The stored archive matches the posted bytes (sha256)
- [x] On disk (or via the signed URL), retrieve the stored archive bytes.
- [x] Compute `sha256sum <archive>`. Compare against `source_archive_sha256` from the DB.
- [x] Hashes match exactly.
- [ ] On disk (or via the signed URL), retrieve the stored archive bytes.
- [ ] Compute `sha256sum <archive>`. Compare against `source_archive_sha256` from the DB.
- [ ] Hashes match exactly.
### B3. The archive contains only tracked files and excludes gitignored
- [x] Setup:
- [ ] Setup:
```bash
rm -rf /tmp/uat-b3 && mkdir /tmp/uat-b3 && cd /tmp/uat-b3
git init -q
@ -115,19 +115,19 @@ uses; commands assume access via `podman exec blockninja-db psql -U orchestrator
echo "ignored.log" > .gitignore
git add .gitignore && git commit -qm "ignore"
```
- [x] Run init + publish for `@themes/uat-b3`.
- [x] Extract the stored archive (download via signed URL → `zstd -d | tar -xv`).
- [x] Observe `plugin.mod`, `.gitignore` present; `ignored.log` absent.
- [ ] Run init + publish for `@themes/uat-b3`.
- [ ] Extract the stored archive (download via signed URL → `zstd -d | tar -xv`).
- [ ] Observe `plugin.mod`, `.gitignore` present; `ignored.log` absent.
### B4. Working tree dirty check fires; `--allow-dirty` bypasses
- [x] In a published repo, touch a file: `echo x >> README.md`.
- [x] Run `ninja plugin publish ...` — must error with text including `working tree dirty`.
- [x] Bump patch, then `ninja plugin publish --allow-dirty ...` — must succeed.
- [ ] In a published repo, touch a file: `echo x >> README.md`.
- [ ] Run `ninja plugin publish ...` — must error with text including `working tree dirty`.
- [ ] Bump patch, then `ninja plugin publish --allow-dirty ...` — must succeed.
### B5. Tracked-yet-gitignored files trigger a warning
- [x] Setup:
- [ ] Setup:
```bash
rm -rf /tmp/uat-b5 && mkdir /tmp/uat-b5 && cd /tmp/uat-b5
git init -q
@ -136,19 +136,19 @@ uses; commands assume access via `podman exec blockninja-db psql -U orchestrator
echo "dist.log" > .gitignore
git add .gitignore && git commit -qm "ignore"
```
- [x] Init + publish for `@themes/uat-b5`.
- [x] STDERR includes a warning that `dist.log` is tracked and will still be shipped, plus the hint `git rm --cached`.
- [ ] Init + publish for `@themes/uat-b5`.
- [ ] STDERR includes a warning that `dist.log` is tracked and will still be shipped, plus the hint `git rm --cached`.
### B6. Duplicate version rejected
- [x] In a successfully-published repo, immediately re-run publish without bumping.
- [x] Error includes `version already published`.
- [ ] In a successfully-published repo, immediately re-run publish without bumping.
- [ ] Error includes `version already published`.
### B7. Version mismatch between plugin.mod and request rejected
- [x] Edit `plugin.mod` to a different version than the one expected by the bump sequence (manually write `version = "9.9.9"` without committing).
- [x] Run publish — must succeed locally but the orchestrator may accept or reject; if rejected the error mentions version mismatch. Either way, no DB row at version `9.9.9` should exist unless it succeeded — record what happened.
- [x] Reset:
- [ ] Edit `plugin.mod` to a different version than the one expected by the bump sequence (manually write `version = "9.9.9"` without committing).
- [ ] Run publish — must succeed locally but the orchestrator may accept or reject; if rejected the error mentions version mismatch. Either way, no DB row at version `9.9.9` should exist unless it succeeded — record what happened.
- [ ] Reset:
```bash
git checkout plugin.mod
```
@ -159,17 +159,17 @@ uses; commands assume access via `podman exec blockninja-db psql -U orchestrator
### C1. ListCategories returns the seeded set
- [x] Run:
- [ ] Run:
```bash
curl -sS -X POST \
https://my.localdev.blockninjacms.com/orchestrator.v1.PluginRegistryService/ListCategories \
-H 'Content-Type: application/json' -d '{}'
```
- [x] Response includes ALL of: `analytics`, `seo`, `social`, `commerce`, `forms`, `import-export`, `media`, `developer`.
- [ ] Response includes ALL of: `analytics`, `seo`, `social`, `commerce`, `forms`, `import-export`, `media`, `developer`.
### C2. CreatePlugin rejects unknown category
- [x] Setup a fresh repo and start init manually entering a bogus category number — NOT possible via the CLI menu. Instead, hit the RPC directly:
- [ ] Setup a fresh repo and start init manually entering a bogus category number — NOT possible via the CLI menu. Instead, hit the RPC directly:
```bash
TOKEN=$(jq -r '.hosts["https://my.localdev.blockninjacms.com"].token' ~/.config/ninja/creds.json)
curl -sS -X POST \
@ -178,11 +178,11 @@ uses; commands assume access via `podman exec blockninja-db psql -U orchestrator
-H 'Content-Type: application/json' \
-d '{"scopeSlug":"themes","name":"uat-c2","kind":"plugin","categories":["bogus"]}'
```
- [x] Response is a Connect error with code `invalid_argument` and message mentioning unknown category.
- [ ] Response is a Connect error with code `invalid_argument` and message mentioning unknown category.
### C3. CreatePlugin rejects theme with categories
- [x] Same setup as C2 but kind=theme with categories=["analytics"]:
- [ ] Same setup as C2 but kind=theme with categories=["analytics"]:
```bash
curl -sS -X POST \
https://my.localdev.blockninjacms.com/orchestrator.v1.PluginRegistryService/CreatePlugin \
@ -190,27 +190,27 @@ uses; commands assume access via `podman exec blockninja-db psql -U orchestrator
-H 'Content-Type: application/json' \
-d '{"scopeSlug":"themes","name":"uat-c3","kind":"theme","categories":["analytics"]}'
```
- [x] Response is invalid_argument with message including "themes do not carry categories".
- [ ] Response is invalid_argument with message including "themes do not carry categories".
### C4. CreatePlugin rejects unknown kind
- [x] POST with `kind: "module"` (or anything other than plugin/theme).
- [x] Response is invalid_argument.
- [ ] POST with `kind: "module"` (or anything other than plugin/theme).
- [ ] Response is invalid_argument.
### C5. PublishVersion rejects plugin.mod kind mismatch
- [x] In a published-once `plugin`-kind repo, hand-edit `plugin.mod` to `kind = "theme"`, commit, bump, publish.
- [x] Publish errors with text including `kind does not match registered`.
- [ ] In a published-once `plugin`-kind repo, hand-edit `plugin.mod` to `kind = "theme"`, commit, bump, publish.
- [ ] Publish errors with text including `kind does not match registered`.
### C6. PublishVersion rejects plugin.mod categories mismatch
- [x] In a published-once `plugin`-kind repo, add a category to `plugin.mod` that wasn't in the original `CreatePlugin`, commit, bump, publish.
- [x] Publish errors with text including `categories do not match registered`.
- [ ] In a published-once `plugin`-kind repo, add a category to `plugin.mod` that wasn't in the original `CreatePlugin`, commit, bump, publish.
- [ ] Publish errors with text including `categories do not match registered`.
### C7. ListPlugins filters by kind
- [x] Setup: at least one `plugin` and one `theme` in the registry (any from earlier UAT items).
- [x] Hit ListPlugins with `kind=plugin`:
- [ ] Setup: at least one `plugin` and one `theme` in the registry (any from earlier UAT items).
- [ ] Hit ListPlugins with `kind=plugin`:
```bash
curl -sS -X POST \
https://my.localdev.blockninjacms.com/orchestrator.v1.PluginRegistryService/ListPlugins \
@ -218,14 +218,14 @@ uses; commands assume access via `podman exec blockninja-db psql -U orchestrator
-H 'Content-Type: application/json' -d '{"kind":"plugin"}'
```
Response contains only kind=plugin rows.
- [x] Same with `{"kind":"theme"}`. Response contains only kind=theme rows.
- [x] With `{}` (no filter). Response contains both.
- [ ] Same with `{"kind":"theme"}`. Response contains only kind=theme rows.
- [ ] With `{}` (no filter). Response contains both.
### C8. ListPlugins filters by category
- [x] Setup a plugin with categories=["analytics"].
- [x] Hit ListPlugins with `{"categories":["analytics"]}`. The plugin appears.
- [x] Hit with `{"categories":["forms"]}`. The plugin does NOT appear.
- [ ] Setup a plugin with categories=["analytics"].
- [ ] Hit ListPlugins with `{"categories":["analytics"]}`. The plugin appears.
- [ ] Hit with `{"categories":["forms"]}`. The plugin does NOT appear.
---
@ -233,46 +233,46 @@ uses; commands assume access via `podman exec blockninja-db psql -U orchestrator
### D1. `/git/*` HTTP route is gone
- [x] Run:
- [ ] Run:
```bash
curl -sS -o /dev/null -w "%{http_code}\n" \
https://my.localdev.blockninjacms.com/git/themes/lcars.git/info/refs?service=git-upload-pack
```
- [x] HTTP code is `404` (not 200, not 403). The route does not exist.
- [ ] HTTP code is `404` (not 200, not 403). The route does not exist.
### D2. `registry/git/` package is fully removed
- [x] Run:
- [ ] Run:
```bash
ls ~/src/orchestrator/backend/internal/registry/ 2>&1
```
- [x] Output does NOT include a `git` directory.
- [x] `grep -r "internal/registry/git" ~/src/orchestrator/backend/ 2>/dev/null | grep -v "^Binary"` returns nothing.
- [ ] Output does NOT include a `git` directory.
- [ ] `grep -r "internal/registry/git" ~/src/orchestrator/backend/ 2>/dev/null | grep -v "^Binary"` returns nothing.
### D3. `RegistryGitPath` config is gone
- [x] Run `grep -n "RegistryGitPath\|REGISTRY_GIT_PATH" ~/src/orchestrator/backend/internal/config/config.go`.
- [x] Output is empty.
- [ ] Run `grep -n "RegistryGitPath\|REGISTRY_GIT_PATH" ~/src/orchestrator/backend/internal/config/config.go`.
- [ ] Output is empty.
### D4. `registry_versions.git_commit` / `git_tag` columns are gone
- [x] Run:
- [ ] Run:
```bash
podman exec blockninja-db psql -U orchestrator orchestrator -c "\d registry_versions"
```
- [x] Output shows NO column named `git_commit` and NO column named `git_tag`.
- [ ] Output shows NO column named `git_commit` and NO column named `git_tag`.
### D5. `Plugin.git_remote_url` is not in the proto
- [x] Run `grep -n "git_remote_url\|GitRemoteUrl" ~/src/orchestrator/proto/orchestrator/v1/plugin_registry.proto ~/src/core/proto/orchestrator/v1/plugin_registry.proto`.
- [x] No matches.
- [ ] Run `grep -n "git_remote_url\|GitRemoteUrl" ~/src/orchestrator/proto/orchestrator/v1/plugin_registry.proto ~/src/core/proto/orchestrator/v1/plugin_registry.proto`.
- [ ] No matches.
### D6. The CLI no longer references the `ninja` git remote
- [x] Run `grep -nE 'remote\s+(add|remove)\s+"?ninja' ~/src/core/cmd/ninja/cmd/plugin.go`.
- [x] No matches.
- [x] Run `grep -nE '"git",\s*"push",\s*"ninja"' ~/src/core/cmd/ninja/cmd/plugin.go`.
- [x] No matches.
- [ ] Run `grep -nE 'remote\s+(add|remove)\s+"?ninja' ~/src/core/cmd/ninja/cmd/plugin.go`.
- [ ] No matches.
- [ ] Run `grep -nE '"git",\s*"push",\s*"ninja"' ~/src/core/cmd/ninja/cmd/plugin.go`.
- [ ] No matches.
---
@ -280,26 +280,26 @@ uses; commands assume access via `podman exec blockninja-db psql -U orchestrator
### E1. ResolveInstall returns the new .tar.zst URL
- [x] For any published plugin/version (e.g. uat-a1@0.1.0), call ResolveInstall:
- [ ] For any published plugin/version (e.g. uat-a1@0.1.0), call ResolveInstall:
```bash
curl -sS -X POST \
https://my.localdev.blockninjacms.com/orchestrator.v1.PluginRegistryService/ResolveInstall \
-H 'Content-Type: application/json' \
-d '{"scopeSlug":"themes","pluginName":"uat-a1","versionOrChannel":"0.1.0"}'
```
- [x] Response contains `archiveUrl` whose path ends with `/source.tar.zst` and includes `sig=` and `exp=` query params.
- [ ] Response contains `archiveUrl` whose path ends with `/source.tar.zst` and includes `sig=` and `exp=` query params.
### E2. The signed URL actually downloads
- [x] `curl -sS -o /tmp/uat-e2.tar.zst "<archiveUrl from E1>"`
- [x] `file /tmp/uat-e2.tar.zst` reports a Zstandard compressed file.
- [x] `sha256sum /tmp/uat-e2.tar.zst` matches the `archiveSha256` from the ResolveInstall response.
- [x] `zstd -dc /tmp/uat-e2.tar.zst | tar -tf - | head` shows `plugin.mod` and the expected files.
- [ ] `curl -sS -o /tmp/uat-e2.tar.zst "<archiveUrl from E1>"`
- [ ] `file /tmp/uat-e2.tar.zst` reports a Zstandard compressed file.
- [ ] `sha256sum /tmp/uat-e2.tar.zst` matches the `archiveSha256` from the ResolveInstall response.
- [ ] `zstd -dc /tmp/uat-e2.tar.zst | tar -tf - | head` shows `plugin.mod` and the expected files.
---
## Sign-off
- [x] Every box above is ticked, with at-the-time-of-execution observation logged inline.
- [x] Any unticked box → work is NOT complete. Return to the plan.
- [x] Final test reports any deviations from expected behaviour even when the box can be ticked.
- [ ] Every box above is ticked, with at-the-time-of-execution observation logged inline.
- [ ] Any unticked box → work is NOT complete. Return to the plan.
- [ ] Final test reports any deviations from expected behaviour even when the box can be ticked.

View File

@ -1,8 +1,6 @@
package plugin
import (
"strings"
tomlpkg "github.com/BurntSushi/toml"
)
@ -41,7 +39,7 @@ func (m *ModFile) Coords() string {
if m == nil {
return ""
}
scope := strings.TrimPrefix(m.Plugin.Scope, "@")
scope := m.Plugin.Scope
if scope == "" {
return m.Plugin.Name + "@" + m.Plugin.Version
}