feat(cli): scope subcommand, interactive scope prompt, bump+version helpers

Pre-existing CLI improvements ahead of the tarball-publish refactor:
- New top-level `ninja scope` command (create, list, set-default).
- `init` accepts no --scope: prompts from ListMyScopes or uses creds default.
- Plugin name prompted if not provided.
- `plugin bump <major|minor|patch>` writes the bumped version into plugin.mod.
- `plugin version` prints the current plugin.mod version.
- `login` prints a URL with ?user_code= so the link is one click.
- creds: HostCreds gains optional default_scope.
- plugin/version: ParseBaseSemver + BumpVersion helpers, with tests.
This commit is contained in:
Alex Dunmow 2026-06-03 01:18:11 +08:00
parent 1d9ca44f55
commit 2a76b30c51
7 changed files with 643 additions and 13 deletions

View File

@ -33,7 +33,7 @@ func newLoginCmd() *cobra.Command {
if err != nil { if err != nil {
return fmt.Errorf("start device: %w", err) return fmt.Errorf("start device: %w", err)
} }
fmt.Printf("Visit %s and enter code: %s\n", start.Msg.VerificationUri, start.Msg.UserCode) fmt.Printf("Visit %s?user_code=%s to authorize.\n", start.Msg.VerificationUri, start.Msg.UserCode)
interval := time.Duration(start.Msg.IntervalSeconds) * time.Second interval := time.Duration(start.Msg.IntervalSeconds) * time.Second
deadline := time.Now().Add(time.Duration(start.Msg.ExpiresInSeconds) * time.Second) deadline := time.Now().Add(time.Duration(start.Msg.ExpiresInSeconds) * time.Second)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {

View File

@ -1,10 +1,14 @@
package cmd package cmd
import ( import (
"bufio"
"context" "context"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"regexp"
"sort"
"strconv"
"strings" "strings"
"connectrpc.com/connect" "connectrpc.com/connect"
@ -19,7 +23,13 @@ import (
func newPluginCmd() *cobra.Command { func newPluginCmd() *cobra.Command {
c := &cobra.Command{Use: "plugin", Short: "Manage BlockNinja plugins"} c := &cobra.Command{Use: "plugin", Short: "Manage BlockNinja plugins"}
c.AddCommand(newPluginInitCmd(), newPluginPublishCmd(), newPluginStatusCmd()) c.AddCommand(
newPluginInitCmd(),
newPluginPublishCmd(),
newPluginStatusCmd(),
newPluginBumpCmd(),
newPluginVersionCmd(),
)
return c return c
} }
@ -39,19 +49,39 @@ func newPluginInitCmd() *cobra.Command {
return err return err
} }
cli := orchclient.New(resolvedHost, hc.Token) cli := orchclient.New(resolvedHost, hc.Token)
scope = strings.TrimPrefix(scope, "@")
if scope == "" || name == "" {
return fmt.Errorf("usage: ninja plugin init --scope ACME --name foo")
}
ctx := context.Background() ctx := context.Background()
scanner := bufio.NewScanner(os.Stdin)
if scope != "" {
scope, err = parseScope(scope)
if err != nil {
return err
}
} else {
scope, err = promptScope(ctx, cli, cr, resolvedHost, hc, scanner)
if err != nil {
return err
}
}
if name == "" {
fmt.Print("Plugin name: ")
if !scanner.Scan() {
return fmt.Errorf("cancelled")
}
name = strings.TrimSpace(scanner.Text())
if name == "" {
return fmt.Errorf("plugin name is required")
}
}
resp, err := cli.Reg.CreatePlugin(ctx, connect.NewRequest(&v1.CreatePluginRequest{ resp, err := cli.Reg.CreatePlugin(ctx, connect.NewRequest(&v1.CreatePluginRequest{
ScopeSlug: scope, Name: name, Description: "", ScopeSlug: scopeAPISlug(scope), Name: name, Description: "",
})) }))
if err != nil { if err != nil {
return err return err
} }
fmt.Printf("Created @%s/%s\n", scope, name) fmt.Printf("\nCreated %s/%s\n", scope, name)
fmt.Printf(" Git remote: %s\n", resp.Msg.GitRemoteUrl) fmt.Printf(" Git remote: %s\n", resp.Msg.GitRemoteUrl)
if _, err := os.Stat(".git"); err == nil { if _, err := os.Stat(".git"); err == nil {
@ -71,11 +101,106 @@ func newPluginInitCmd() *cobra.Command {
return nil return nil
}, },
} }
cmd.Flags().StringVar(&scope, "scope", "", "Scope slug (with or without @)") cmd.Flags().StringVar(&scope, "scope", "", "Scope (e.g. @acme)")
cmd.Flags().StringVar(&name, "name", "", "Plugin name") cmd.Flags().StringVar(&name, "name", "", "Plugin name")
return cmd return cmd
} }
func promptScope(ctx context.Context, cli *orchclient.Client, cr *creds.Credentials, host string, hc creds.HostCreds, scanner *bufio.Scanner) (string, error) {
if hc.DefaultScope != "" {
fmt.Printf("Using default scope: %s (override with --scope)\n", hc.DefaultScope)
return hc.DefaultScope, nil
}
scopes, err := cli.Scope.ListMyScopes(ctx, connect.NewRequest(&v1.ListMyScopesRequest{}))
if err != nil {
return "", fmt.Errorf("listing scopes: %w", err)
}
fmt.Println()
fmt.Println("A scope is your organisation or personal namespace for plugins (like @mycompany).")
fmt.Println()
var scope string
if len(scopes.Msg.Scopes) == 0 {
fmt.Println("You don't have any scopes yet. Let's create one.")
fmt.Println()
s, err := createScopeInline(ctx, cli, scanner)
if err != nil {
return "", err
}
scope = s
} else {
fmt.Println("Your scopes:")
for i, s := range scopes.Msg.Scopes {
fmt.Printf(" %d. @%s — %s\n", i+1, s.Slug, s.DisplayName)
}
fmt.Println()
fmt.Print("Select a scope [1]: ")
if !scanner.Scan() {
return "", fmt.Errorf("cancelled")
}
input := strings.TrimSpace(scanner.Text())
if input == "" {
scope = "@" + scopes.Msg.Scopes[0].Slug
} else if n, err := strconv.Atoi(input); err == nil && n >= 1 && n <= len(scopes.Msg.Scopes) {
scope = "@" + scopes.Msg.Scopes[n-1].Slug
} else {
return "", fmt.Errorf("invalid selection: %s", input)
}
}
fmt.Printf("\nSave %s as your default scope? [Y/n]: ", scope)
if scanner.Scan() {
ans := strings.ToLower(strings.TrimSpace(scanner.Text()))
if ans == "" || ans == "y" || ans == "yes" {
hc.DefaultScope = scope
cr.Hosts[host] = hc
if err := cr.Save(); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not save default scope: %v\n", err)
} else {
fmt.Println("Default scope saved.")
}
}
}
return scope, nil
}
func createScopeInline(ctx context.Context, cli *orchclient.Client, scanner *bufio.Scanner) (string, error) {
fmt.Println("A scope is an organisation namespace for your plugins (e.g. @acme).")
fmt.Println("It appears in plugin names like @acme/my-plugin.")
fmt.Println()
fmt.Print("Scope slug (lowercase letters, numbers, dashes): ")
if !scanner.Scan() {
return "", fmt.Errorf("cancelled")
}
slug, err := parseScope(scanner.Text())
if err != nil {
return "", err
}
fmt.Printf("Display name [%s]: ", scopeAPISlug(slug))
if !scanner.Scan() {
return "", fmt.Errorf("cancelled")
}
displayName := strings.TrimSpace(scanner.Text())
if displayName == "" {
displayName = scopeAPISlug(slug)
}
_, err = cli.Scope.CreateScope(ctx, connect.NewRequest(&v1.CreateScopeRequest{
Slug: scopeAPISlug(slug),
DisplayName: displayName,
}))
if err != nil {
return "", err
}
fmt.Printf("Created scope %s\n\n", slug)
return slug, nil
}
func newPluginPublishCmd() *cobra.Command { func newPluginPublishCmd() *cobra.Command {
var channel string var channel string
var allowDirty bool var allowDirty bool
@ -177,7 +302,7 @@ func newPluginStatusCmd() *cobra.Command {
return err return err
} }
if len(scopes.Msg.Scopes) == 0 { if len(scopes.Msg.Scopes) == 0 {
fmt.Println("No scopes yet. Create one in the orchestrator UI or via a CreateScope call.") fmt.Println("No scopes yet. Create one with: ninja scope create")
return nil return nil
} }
for _, s := range scopes.Msg.Scopes { for _, s := range scopes.Msg.Scopes {
@ -196,6 +321,159 @@ func newPluginStatusCmd() *cobra.Command {
} }
} }
func newPluginBumpCmd() *cobra.Command {
var setVersion string
cmd := &cobra.Command{
Use: "bump [major|minor|patch]",
Short: "Bump the version in plugin.mod (default: patch)",
Long: "Updates plugin.mod with a new version. Does not commit or tag.",
Args: cobra.MaximumNArgs(1),
RunE: func(c *cobra.Command, args []string) error {
modBytes, err := os.ReadFile("plugin.mod")
if err != nil {
return fmt.Errorf("read plugin.mod: %w", err)
}
mod, err := core.ParseModFull(modBytes)
if err != nil {
return err
}
if mod.Plugin.Version == "" {
return fmt.Errorf("plugin.mod has no version")
}
old := mod.Plugin.Version
var next string
if setVersion != "" {
if len(args) > 0 {
return fmt.Errorf("cannot combine --set with bump argument")
}
if _, _, _, err := core.ParseBaseSemver(setVersion); err != nil {
return err
}
next = setVersion
} else {
level := "patch"
if len(args) > 0 {
level = args[0]
}
next, err = core.BumpVersion(old, level)
if err != nil {
return err
}
}
mod.Plugin.Version = next
if err := writeMod("plugin.mod", mod); err != nil {
return err
}
fmt.Printf("%s -> %s\n", old, next)
return nil
},
}
cmd.Flags().StringVar(&setVersion, "set", "", "Set explicit version (e.g. 0.5.0)")
return cmd
}
func newPluginVersionCmd() *cobra.Command {
var short bool
cmd := &cobra.Command{
Use: "version",
Short: "Show local plugin version and registry channels",
RunE: func(c *cobra.Command, _ []string) error {
modBytes, err := os.ReadFile("plugin.mod")
if err != nil {
return fmt.Errorf("read plugin.mod: %w", err)
}
mod, err := core.ParseModFull(modBytes)
if err != nil {
return err
}
if short {
fmt.Println(mod.Plugin.Version)
return nil
}
local := mod.Plugin.Version
if local == "" {
local = "(unset)"
}
fmt.Printf("local: %s\n", local)
host, _ := c.Flags().GetString("host")
cr, err := creds.Load()
if err != nil {
fmt.Printf("(registry: %v)\n", err)
return nil
}
resolvedHost, hc, err := cr.Resolve(host)
if err != nil {
fmt.Printf("(registry: %v)\n", err)
return nil
}
if mod.Plugin.Scope == "" || mod.Plugin.Name == "" {
fmt.Println("(registry: plugin.mod missing scope or name)")
return nil
}
cli := orchclient.New(resolvedHost, hc.Token)
pr, err := cli.Reg.GetPlugin(context.Background(), connect.NewRequest(&v1.GetPluginRequest{
ScopeSlug: mod.Plugin.Scope, Name: mod.Plugin.Name,
}))
if err != nil {
fmt.Printf("(registry: %v)\n", err)
return nil
}
verByString := make(map[string]*v1.Version, len(pr.Msg.Versions))
for _, v := range pr.Msg.Versions {
verByString[v.Version] = v
}
names := make([]string, 0, len(pr.Msg.Channels))
for ch := range pr.Msg.Channels {
names = append(names, ch)
}
sort.Slice(names, func(i, j int) bool {
if names[i] == "latest" {
return true
}
if names[j] == "latest" {
return false
}
return names[i] < names[j]
})
for _, ch := range names {
ver := pr.Msg.Channels[ch]
date := ""
if v, ok := verByString[ver]; ok && v.PublishedAt != nil {
date = " (published " + v.PublishedAt.AsTime().Format("2006-01-02") + ")"
}
fmt.Printf("%-8s %s%s\n", ch+":", ver, date)
}
return nil
},
}
cmd.Flags().BoolVar(&short, "short", false, "Print only the local version")
return cmd
}
var scopeSlugRe = regexp.MustCompile(`^[a-z][a-z0-9-]{2,}$`)
func parseScope(input string) (string, error) {
raw := strings.TrimPrefix(strings.TrimSpace(input), "@")
if raw == "" {
return "", fmt.Errorf("scope is required")
}
if !scopeSlugRe.MatchString(raw) {
return "", fmt.Errorf("invalid scope %q: must be at least 3 characters, lowercase letters, numbers, and dashes", raw)
}
return "@" + raw, nil
}
func scopeAPISlug(scope string) string {
return strings.TrimPrefix(scope, "@")
}
func runCmd(name string, args ...string) error { func runCmd(name string, args ...string) error {
c := exec.Command(name, args...) c := exec.Command(name, args...)
c.Stderr = os.Stderr c.Stderr = os.Stderr

View File

@ -14,5 +14,6 @@ func NewRoot() *cobra.Command {
root.AddCommand(newLogoutCmd()) root.AddCommand(newLogoutCmd())
root.AddCommand(newWhoamiCmd()) root.AddCommand(newWhoamiCmd())
root.AddCommand(newPluginCmd()) root.AddCommand(newPluginCmd())
root.AddCommand(newScopeCmd())
return root return root
} }

230
cmd/ninja/cmd/scope.go Normal file
View File

@ -0,0 +1,230 @@
package cmd
import (
"bufio"
"context"
"fmt"
"os"
"strconv"
"strings"
"connectrpc.com/connect"
"github.com/spf13/cobra"
"git.dev.alexdunmow.com/block/core/cmd/ninja/internal/creds"
"git.dev.alexdunmow.com/block/core/cmd/ninja/internal/orchclient"
v1 "git.dev.alexdunmow.com/block/core/internal/api/orchestrator/v1"
)
func newScopeCmd() *cobra.Command {
c := &cobra.Command{Use: "scope", Short: "Manage plugin scopes"}
c.AddCommand(newScopeCreateCmd(), newScopeListCmd(), newScopeDefaultCmd())
return c
}
func newScopeCreateCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "create [scope]",
Short: "Create a new scope (organisation namespace for plugins)",
Args: cobra.MaximumNArgs(1),
RunE: func(c *cobra.Command, args []string) error {
host, _ := c.Flags().GetString("host")
cr, err := creds.Load()
if err != nil {
return err
}
resolvedHost, hc, err := cr.Resolve(host)
if err != nil {
return err
}
cli := orchclient.New(resolvedHost, hc.Token)
ctx := context.Background()
scanner := bufio.NewScanner(os.Stdin)
var slug string
if len(args) > 0 {
slug, err = parseScope(args[0])
if err != nil {
return err
}
} else {
slug, err = promptScopeSlug(scanner)
if err != nil {
return err
}
}
fmt.Printf("Display name [%s]: ", scopeAPISlug(slug))
displayName := scopeAPISlug(slug)
if scanner.Scan() {
if v := strings.TrimSpace(scanner.Text()); v != "" {
displayName = v
}
}
_, err = cli.Scope.CreateScope(ctx, connect.NewRequest(&v1.CreateScopeRequest{
Slug: scopeAPISlug(slug),
DisplayName: displayName,
}))
if err != nil {
return err
}
fmt.Printf("Created scope %s\n", slug)
fmt.Printf("Set %s as your default scope? [Y/n]: ", slug)
if scanner.Scan() {
ans := strings.ToLower(strings.TrimSpace(scanner.Text()))
if ans == "" || ans == "y" || ans == "yes" {
hc.DefaultScope = slug
cr.Hosts[resolvedHost] = hc
if err := cr.Save(); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not save default scope: %v\n", err)
} else {
fmt.Println("Default scope saved.")
}
}
}
return nil
},
}
return cmd
}
func promptScopeSlug(scanner *bufio.Scanner) (string, error) {
fmt.Println("A scope is an organisation namespace for your plugins (e.g. @acme).")
fmt.Println("It appears in plugin names like @acme/my-plugin.")
fmt.Println()
fmt.Print("Scope slug (lowercase letters, numbers, dashes): ")
if !scanner.Scan() {
return "", fmt.Errorf("cancelled")
}
return parseScope(scanner.Text())
}
func newScopeDefaultCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "default",
Short: "Show or change the default scope",
RunE: func(c *cobra.Command, _ []string) error {
host, _ := c.Flags().GetString("host")
cr, err := creds.Load()
if err != nil {
return err
}
_, hc, err := cr.Resolve(host)
if err != nil {
return err
}
if hc.DefaultScope == "" {
fmt.Println("No default scope set. Run: ninja scope default set")
} else {
fmt.Println(hc.DefaultScope)
}
return nil
},
}
cmd.AddCommand(newScopeDefaultSetCmd())
return cmd
}
func newScopeDefaultSetCmd() *cobra.Command {
return &cobra.Command{
Use: "set",
Short: "Pick a default scope from your scopes",
RunE: func(c *cobra.Command, _ []string) error {
host, _ := c.Flags().GetString("host")
cr, err := creds.Load()
if err != nil {
return err
}
resolvedHost, hc, err := cr.Resolve(host)
if err != nil {
return err
}
cli := orchclient.New(resolvedHost, hc.Token)
ctx := context.Background()
scopes, err := cli.Scope.ListMyScopes(ctx, connect.NewRequest(&v1.ListMyScopesRequest{}))
if err != nil {
return err
}
if len(scopes.Msg.Scopes) == 0 {
fmt.Println("No scopes yet. Create one with: ninja scope create")
return nil
}
fmt.Println("Your scopes:")
for i, s := range scopes.Msg.Scopes {
marker := ""
if "@"+s.Slug == hc.DefaultScope {
marker = " (current)"
}
fmt.Printf(" %d. @%s — %s%s\n", i+1, s.Slug, s.DisplayName, marker)
}
fmt.Println()
scanner := bufio.NewScanner(os.Stdin)
fmt.Print("Select a scope: ")
if !scanner.Scan() {
return fmt.Errorf("cancelled")
}
input := strings.TrimSpace(scanner.Text())
if input == "" {
return fmt.Errorf("cancelled")
}
var scope string
if n, err := strconv.Atoi(input); err == nil && n >= 1 && n <= len(scopes.Msg.Scopes) {
scope = "@" + scopes.Msg.Scopes[n-1].Slug
} else {
return fmt.Errorf("invalid selection: %s", input)
}
hc.DefaultScope = scope
cr.Hosts[resolvedHost] = hc
if err := cr.Save(); err != nil {
return err
}
fmt.Printf("Default scope set to %s\n", scope)
return nil
},
}
}
func newScopeListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List your scopes",
RunE: func(c *cobra.Command, _ []string) error {
host, _ := c.Flags().GetString("host")
cr, err := creds.Load()
if err != nil {
return err
}
resolvedHost, hc, err := cr.Resolve(host)
if err != nil {
return err
}
cli := orchclient.New(resolvedHost, hc.Token)
ctx := context.Background()
scopes, err := cli.Scope.ListMyScopes(ctx, connect.NewRequest(&v1.ListMyScopesRequest{}))
if err != nil {
return err
}
if len(scopes.Msg.Scopes) == 0 {
fmt.Println("No scopes yet. Create one with: ninja scope create")
return nil
}
for _, s := range scopes.Msg.Scopes {
marker := ""
if "@"+s.Slug == hc.DefaultScope {
marker = " (default)"
}
fmt.Printf("@%s — %s%s\n", s.Slug, s.DisplayName, marker)
}
return nil
},
}
}

View File

@ -13,8 +13,9 @@ type Credentials struct {
} }
type HostCreds struct { type HostCreds struct {
Token string `json:"token"` Token string `json:"token"`
User string `json:"user,omitempty"` User string `json:"user,omitempty"`
DefaultScope string `json:"default_scope,omitempty"`
} }
func filePath() (string, error) { func filePath() (string, error) {

View File

@ -3,6 +3,8 @@ package plugin
import ( import (
"bufio" "bufio"
"bytes" "bytes"
"fmt"
"strconv"
"strings" "strings"
"golang.org/x/mod/semver" "golang.org/x/mod/semver"
@ -44,3 +46,44 @@ func CompareVersions(v1, v2 string) int {
} }
return semver.Compare(v1, v2) return semver.Compare(v1, v2)
} }
// ParseBaseSemver parses a plain MAJOR.MINOR.PATCH version into integers.
// Rejects pre-release suffixes and build metadata.
func ParseBaseSemver(s string) (major, minor, patch int, err error) {
parts := strings.Split(s, ".")
if len(parts) != 3 {
return 0, 0, 0, fmt.Errorf("invalid version %q: expected MAJOR.MINOR.PATCH", s)
}
out := [3]int{}
for i, p := range parts {
n, perr := strconv.Atoi(p)
if perr != nil || n < 0 {
return 0, 0, 0, fmt.Errorf("invalid version %q: each part must be a non-negative integer", s)
}
out[i] = n
}
return out[0], out[1], out[2], nil
}
// BumpVersion returns current bumped at the given level ("major", "minor", or "patch").
// Bumping major resets minor and patch to 0; bumping minor resets patch to 0.
func BumpVersion(current, level string) (string, error) {
major, minor, patch, err := ParseBaseSemver(current)
if err != nil {
return "", err
}
switch level {
case "major":
major++
minor = 0
patch = 0
case "minor":
minor++
patch = 0
case "patch":
patch++
default:
return "", fmt.Errorf("unknown bump level %q (want major|minor|patch)", level)
}
return fmt.Sprintf("%d.%d.%d", major, minor, patch), nil
}

77
plugin/version_test.go Normal file
View File

@ -0,0 +1,77 @@
package plugin
import "testing"
func TestParseBaseSemver(t *testing.T) {
cases := []struct {
in string
major, minor, patch int
wantErr bool
}{
{"0.1.0", 0, 1, 0, false},
{"1.0.0", 1, 0, 0, false},
{"12.34.567", 12, 34, 567, false},
{"0.0.0", 0, 0, 0, false},
{"v0.1.0", 0, 0, 0, true},
{"0.1", 0, 0, 0, true},
{"0.1.0.0", 0, 0, 0, true},
{"0.1.0-beta", 0, 0, 0, true},
{"0.1.0+build", 0, 0, 0, true},
{"-1.0.0", 0, 0, 0, true},
{"a.b.c", 0, 0, 0, true},
{"", 0, 0, 0, true},
}
for _, c := range cases {
t.Run(c.in, func(t *testing.T) {
maj, min, pat, err := ParseBaseSemver(c.in)
if c.wantErr {
if err == nil {
t.Fatalf("ParseBaseSemver(%q): expected error, got %d.%d.%d", c.in, maj, min, pat)
}
return
}
if err != nil {
t.Fatalf("ParseBaseSemver(%q): unexpected error: %v", c.in, err)
}
if maj != c.major || min != c.minor || pat != c.patch {
t.Errorf("ParseBaseSemver(%q) = %d.%d.%d, want %d.%d.%d", c.in, maj, min, pat, c.major, c.minor, c.patch)
}
})
}
}
func TestBumpVersion(t *testing.T) {
cases := []struct {
current, level, want string
wantErr bool
}{
{"0.1.0", "patch", "0.1.1", false},
{"0.1.0", "minor", "0.2.0", false},
{"0.1.0", "major", "1.0.0", false},
{"1.2.3", "patch", "1.2.4", false},
{"1.2.3", "minor", "1.3.0", false},
{"1.2.3", "major", "2.0.0", false},
{"0.0.0", "patch", "0.0.1", false},
{"0.1.0", "build", "", true},
{"0.1.0", "", "", true},
{"v0.1.0", "patch", "", true},
{"", "patch", "", true},
}
for _, c := range cases {
t.Run(c.current+"/"+c.level, func(t *testing.T) {
got, err := BumpVersion(c.current, c.level)
if c.wantErr {
if err == nil {
t.Fatalf("BumpVersion(%q, %q): expected error, got %q", c.current, c.level, got)
}
return
}
if err != nil {
t.Fatalf("BumpVersion(%q, %q): unexpected error: %v", c.current, c.level, err)
}
if got != c.want {
t.Errorf("BumpVersion(%q, %q) = %q, want %q", c.current, c.level, got, c.want)
}
})
}
}