Pull the three inline warning blocks in newPluginPublishCmd — gitignoredTrackedWarning, untrackedFilesWarning, submoduleWarning — into package-private helpers that take a repo dir and an io.Writer. Output is byte-identical to the previous inline code; this just makes them unit- testable without driving the whole cobra command.
713 lines
20 KiB
Go
713 lines
20 KiB
Go
package cmd
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"connectrpc.com/connect"
|
|
"github.com/spf13/cobra"
|
|
|
|
core "git.dev.alexdunmow.com/block/core/plugin"
|
|
|
|
"git.dev.alexdunmow.com/block/core/cmd/ninja/internal/archive"
|
|
"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 newPluginCmd() *cobra.Command {
|
|
c := &cobra.Command{Use: "plugin", Short: "Manage BlockNinja plugins"}
|
|
c.AddCommand(
|
|
newPluginInitCmd(),
|
|
newPluginPublishCmd(),
|
|
newPluginStatusCmd(),
|
|
newPluginBumpCmd(),
|
|
newPluginVersionCmd(),
|
|
)
|
|
return c
|
|
}
|
|
|
|
func newPluginInitCmd() *cobra.Command {
|
|
var scope, name string
|
|
cmd := &cobra.Command{
|
|
Use: "init",
|
|
Short: "Create a plugin in the registry and add a git remote",
|
|
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()
|
|
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")
|
|
}
|
|
}
|
|
|
|
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,
|
|
})); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("\nCreated %s/%s\n", scope, name)
|
|
|
|
if err := upsertPluginMod(scope, name, kind, cats); err != nil {
|
|
return err
|
|
}
|
|
fmt.Println("plugin.mod updated")
|
|
|
|
if _, err := os.Stat(".git"); err == nil {
|
|
if err := autoCommitPluginMod(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "warning: could not commit plugin.mod: %v\n", err)
|
|
}
|
|
} else {
|
|
fmt.Println("Not in a git repo - run `git init && git commit` before `ninja plugin publish`")
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().StringVar(&scope, "scope", "", "Scope (e.g. @acme)")
|
|
cmd.Flags().StringVar(&name, "name", "", "Plugin name")
|
|
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 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
|
|
cmd := &cobra.Command{
|
|
Use: "publish",
|
|
Short: "Build a source archive and publish a new version to the registry",
|
|
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)
|
|
|
|
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.Scope == "" || mod.Plugin.Name == "" || mod.Plugin.Version == "" {
|
|
return fmt.Errorf("plugin.mod must have scope, name, and version")
|
|
}
|
|
|
|
if err := checkRepoHasHEAD("."); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Run the tracked-yet-gitignored warning BEFORE the dirty check so
|
|
// the developer sees it even on the aborted-publish path; the spec
|
|
// asks for this warning to be unconditional.
|
|
gitignoredTrackedWarning(".", os.Stderr)
|
|
|
|
if !allowDirty {
|
|
out, _ := exec.Command("git", "status", "--porcelain").Output()
|
|
if len(strings.TrimSpace(string(out))) > 0 {
|
|
return fmt.Errorf("working tree dirty; commit or pass --allow-dirty")
|
|
}
|
|
} else {
|
|
// `git stash create` only captures tracked content, so untracked
|
|
// files would be silently dropped from the archive. Warn loudly.
|
|
untrackedFilesWarning(".", os.Stderr)
|
|
}
|
|
|
|
// `git archive` does not recurse into submodules, so any submodule
|
|
// paths will appear as empty directories in the tarball. Detect via
|
|
// .gitmodules so this works even for submodules that haven't been
|
|
// initialised yet.
|
|
submoduleWarning(".", os.Stderr)
|
|
|
|
archiveBytes, err := archive.BuildSourceArchive(".")
|
|
if err != nil {
|
|
return fmt.Errorf("build archive: %w", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
pr, err := cli.Reg.GetPlugin(ctx, connect.NewRequest(&v1.GetPluginRequest{
|
|
ScopeSlug: mod.Plugin.Scope, Name: mod.Plugin.Name,
|
|
}))
|
|
if err != nil {
|
|
return fmt.Errorf("get plugin: %w", err)
|
|
}
|
|
|
|
readme, _ := os.ReadFile("README.md")
|
|
changelog, _ := os.ReadFile("CHANGELOG.md")
|
|
|
|
pubResp, err := cli.Pub.PublishVersion(ctx, connect.NewRequest(&v1.PublishVersionRequest{
|
|
PluginId: pr.Msg.Plugin.Id,
|
|
Version: mod.Plugin.Version,
|
|
Channel: channel,
|
|
Archive: archiveBytes,
|
|
ReadmeMd: string(readme),
|
|
ChangelogMd: string(changelog),
|
|
}))
|
|
if err != nil {
|
|
return fmt.Errorf("publish: %w", err)
|
|
}
|
|
fmt.Printf("Published %s (%d bytes)\n", mod.Coords(), len(archiveBytes))
|
|
for _, w := range pubResp.Msg.Warnings {
|
|
fmt.Printf(" warning: %s\n", w)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().StringVar(&channel, "channel", "latest", "Channel to point at this version")
|
|
cmd.Flags().BoolVar(&allowDirty, "allow-dirty", false, "Skip clean-working-tree check")
|
|
return cmd
|
|
}
|
|
|
|
func newPluginStatusCmd() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "status",
|
|
Short: "List owned scopes and their plugins",
|
|
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 {
|
|
fmt.Printf("@%s - %s\n", s.Slug, s.DisplayName)
|
|
gs, err := cli.Scope.GetScope(ctx, connect.NewRequest(&v1.GetScopeRequest{Slug: s.Slug}))
|
|
if err != nil {
|
|
fmt.Printf(" (error: %v)\n", err)
|
|
continue
|
|
}
|
|
for _, p := range gs.Msg.Plugins {
|
|
fmt.Printf(" @%s/%s [%s]\n", s.Slug, p.Name, p.Visibility)
|
|
}
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
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 {
|
|
c := exec.Command(name, args...)
|
|
c.Stderr = os.Stderr
|
|
return c.Run()
|
|
}
|
|
|
|
// checkRepoHasHEAD returns a friendlier error than the raw git failure when
|
|
// the publish flow is invoked in a freshly-initialised repository that has
|
|
// no commits yet. Without this, `git stash create` reports "You do not have
|
|
// the initial commit yet" via exit status 128, which surfaces as an internal
|
|
// failure to the user.
|
|
func checkRepoHasHEAD(repoDir string) error {
|
|
cmd := exec.Command("git", "rev-parse", "--verify", "HEAD")
|
|
cmd.Dir = repoDir
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("no commits in repository; run `git add . && git commit` before publishing")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// submodulePaths returns the configured submodule paths from .gitmodules.
|
|
// Reading the file directly (rather than running `git submodule status`) means
|
|
// we detect submodules that have been declared but not yet initialised.
|
|
func submodulePaths(repoDir string) []string {
|
|
cmd := exec.Command("git", "config", "--file", ".gitmodules", "--get-regexp", `submodule\..*\.path`)
|
|
cmd.Dir = repoDir
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var paths []string
|
|
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
|
// each line is "submodule.<name>.path <path>"
|
|
fields := strings.Fields(line)
|
|
if len(fields) >= 2 {
|
|
paths = append(paths, fields[len(fields)-1])
|
|
}
|
|
}
|
|
return paths
|
|
}
|
|
|
|
func upsertPluginMod(scope, name, kind string, categories []string) error {
|
|
const file = "plugin.mod"
|
|
existing, _ := os.ReadFile(file)
|
|
mod, _ := core.ParseModFull(existing)
|
|
if mod == nil {
|
|
mod = &core.ModFile{}
|
|
}
|
|
if mod.Plugin.Version == "" {
|
|
mod.Plugin.Version = "0.1.0"
|
|
}
|
|
mod.Plugin.Scope = scope
|
|
mod.Plugin.Name = name
|
|
mod.Plugin.Kind = kind
|
|
mod.Plugin.Categories = categories
|
|
return writeMod(file, mod)
|
|
}
|
|
|
|
func writeMod(path string, m *core.ModFile) error {
|
|
var b strings.Builder
|
|
b.WriteString("[plugin]\n")
|
|
b.WriteString(fmt.Sprintf("name = %q\n", m.Plugin.Name))
|
|
b.WriteString(fmt.Sprintf("scope = %q\n", m.Plugin.Scope))
|
|
b.WriteString(fmt.Sprintf("version = %q\n", m.Plugin.Version))
|
|
if m.Plugin.Kind != "" {
|
|
b.WriteString(fmt.Sprintf("kind = %q\n", m.Plugin.Kind))
|
|
}
|
|
if len(m.Plugin.Categories) > 0 {
|
|
quoted := make([]string, len(m.Plugin.Categories))
|
|
for i, c := range m.Plugin.Categories {
|
|
quoted[i] = fmt.Sprintf("%q", c)
|
|
}
|
|
b.WriteString(fmt.Sprintf("categories = [%s]\n", strings.Join(quoted, ", ")))
|
|
}
|
|
if m.Compatibility != nil {
|
|
b.WriteString("\n[compatibility]\n")
|
|
b.WriteString(fmt.Sprintf("block_core = %q\n", m.Compatibility.BlockCore))
|
|
}
|
|
for _, r := range m.Requires {
|
|
b.WriteString("\n[[requires]]\n")
|
|
b.WriteString(fmt.Sprintf("name = %q\n", r.Name))
|
|
b.WriteString(fmt.Sprintf("version = %q\n", r.Version))
|
|
}
|
|
return os.WriteFile(path, []byte(b.String()), 0o644)
|
|
}
|
|
|
|
// gitignoredTrackedWarning writes a warning to w if any tracked files
|
|
// in repoDir match the active .gitignore — those files still ship in the
|
|
// archive, which is almost always not what the author wants.
|
|
func gitignoredTrackedWarning(repoDir string, w io.Writer) {
|
|
cmd := exec.Command("git", "ls-files", "--cached", "--ignored", "--exclude-standard")
|
|
cmd.Dir = repoDir
|
|
out, _ := cmd.Output()
|
|
names := strings.TrimSpace(string(out))
|
|
if names == "" {
|
|
return
|
|
}
|
|
fmt.Fprintln(w, "warning: these tracked files match .gitignore and will still be shipped:")
|
|
for _, n := range strings.Split(names, "\n") {
|
|
fmt.Fprintln(w, " "+n)
|
|
}
|
|
fmt.Fprintln(w, " (run `git rm --cached <file>` to drop)")
|
|
}
|
|
|
|
// untrackedFilesWarning writes a warning to w listing untracked files in
|
|
// repoDir. Used on the --allow-dirty publish path because `git stash create`
|
|
// only captures tracked content, so untracked files silently vanish from the
|
|
// archive.
|
|
func untrackedFilesWarning(repoDir string, w io.Writer) {
|
|
cmd := exec.Command("git", "ls-files", "--others", "--exclude-standard")
|
|
cmd.Dir = repoDir
|
|
out, _ := cmd.Output()
|
|
names := strings.TrimSpace(string(out))
|
|
if names == "" {
|
|
return
|
|
}
|
|
fmt.Fprintln(w, "warning: --allow-dirty: these untracked files will NOT be in the archive:")
|
|
for _, n := range strings.Split(names, "\n") {
|
|
fmt.Fprintln(w, " "+n)
|
|
}
|
|
fmt.Fprintln(w, " (run `git add <file>` if they should be shipped)")
|
|
}
|
|
|
|
// submoduleWarning writes a warning to w if repoDir contains submodules.
|
|
// `git archive` doesn't recurse into them so they ship as empty directories;
|
|
// detection is via .gitmodules so it fires even for uninitialised submodules.
|
|
func submoduleWarning(repoDir string, w io.Writer) {
|
|
paths := submodulePaths(repoDir)
|
|
if len(paths) == 0 {
|
|
return
|
|
}
|
|
fmt.Fprintln(w, "warning: this repo has submodules; git archive will ship them as empty directories:")
|
|
for _, p := range paths {
|
|
fmt.Fprintln(w, " "+p)
|
|
}
|
|
fmt.Fprintln(w, " (vendor the contents or pack them separately if the plugin depends on them)")
|
|
}
|
|
|
|
// autoCommitPluginMod stages and commits plugin.mod if it differs from
|
|
// what's already at HEAD. No-op when there's nothing to commit.
|
|
func autoCommitPluginMod() error {
|
|
out, err := exec.Command("git", "status", "--porcelain", "plugin.mod").Output()
|
|
if err != nil {
|
|
return fmt.Errorf("git status: %w", err)
|
|
}
|
|
if strings.TrimSpace(string(out)) == "" {
|
|
return nil
|
|
}
|
|
if err := runCmd("git", "add", "plugin.mod"); err != nil {
|
|
return err
|
|
}
|
|
if err := runCmd("git", "commit", "-m", "Add plugin.mod"); err != nil {
|
|
return err
|
|
}
|
|
fmt.Println("Committed plugin.mod")
|
|
return nil
|
|
}
|