Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
199 changes: 199 additions & 0 deletions cli/gonext/cmd/plugin/init.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
package plugin

import (
"embed"
"flag"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
)

// runInit implements `gonext plugin init [--template=go] <project-dir>`.
//
// It scaffolds a fresh plugin project from one of the embedded
// templates. Today only --template=go is supported (the
// TinyGo-targeted SDK template); future templates (rust,
// assemblyscript) land here as additional subdirs.
//
// The scaffold writes:
//
// <project-dir>/main.go the entry point with one action +
// one filter stub and a manifest
// builder
// <project-dir>/manifest.json the canonical manifest (matches
// the SDK's builder output)
// <project-dir>/go.mod the Go module declaration
// <project-dir>/Makefile build / bundle / test targets
// <project-dir>/.gitignore exclude plugin.wasm, *.gnplugin
//
// Existing files at the target are NOT overwritten unless --force is
// passed. The conservative default surfaces a clear error so a user
// doesn't lose work by accident.

func runInit(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("gonext plugin init", flag.ContinueOnError)
fs.SetOutput(stderr)
fs.Usage = func() {
fmt.Fprintln(stderr, initUsage)
}

template := fs.String("template", "go", "template name (go is the only choice today)")
pluginName := fs.String("name", "", "plugin slug for the manifest (default: project dir basename)")
force := fs.Bool("force", false, "overwrite existing files at the target")

if err := fs.Parse(args); err != nil {
if err == flag.ErrHelp {
return ExitOK
}
return ExitUsage
}

rest := fs.Args()
if len(rest) == 0 {
fmt.Fprintln(stderr, "gonext plugin init: missing project directory")
fmt.Fprintln(stderr, initUsage)
return ExitUsage
}
if len(rest) > 1 {
fmt.Fprintf(stderr, "gonext plugin init: unexpected extra arguments: %v\n", rest[1:])
fmt.Fprintln(stderr, initUsage)
return ExitUsage
}

projectDir, err := filepath.Abs(rest[0])
if err != nil {
fmt.Fprintf(stderr, "gonext plugin init: resolving project dir: %s\n", err)
return ExitFail
}
slug := *pluginName
if slug == "" {
slug = sanitizeSlug(filepath.Base(projectDir))
}

if *template != "go" {
fmt.Fprintf(stderr, "gonext plugin init: unknown template %q (only \"go\" is supported)\n", *template)
return ExitUsage
}

if err := os.MkdirAll(projectDir, 0o755); err != nil {
fmt.Fprintf(stderr, "gonext plugin init: creating project dir: %s\n", err)
return ExitFail
}

if err := writeTemplateGo(projectDir, slug, *force); err != nil {
fmt.Fprintf(stderr, "gonext plugin init: %s\n", err)
return ExitFail
}

fmt.Fprintf(stdout, "Initialized GoNext Go plugin in %s\n", projectDir)
fmt.Fprintln(stdout, "Next steps:")
fmt.Fprintln(stdout, " cd "+projectDir)
fmt.Fprintln(stdout, " go mod tidy")
fmt.Fprintln(stdout, " make # builds plugin.wasm via TinyGo")
fmt.Fprintln(stdout, " make bundle # packs the .gnplugin ZIP")
return ExitOK
}

const initUsage = `gonext plugin init — scaffold a new plugin project

Usage:
gonext plugin init [flags] <project-dir>

Flags:
--template=<name> template to use (default: go)
--name=<slug> plugin slug to embed in manifest.json (default: basename
of <project-dir>)
--force overwrite existing files at the target

Templates:
go TinyGo-targeted Go plugin using packages/go/sdk

Example:
gonext plugin init --template=go ./my-plugin`

// templatesFS embeds the templates directory tree. Each file is
// rendered by trivial token substitution — {{PLUGIN_NAME}} becomes
// the manifest slug. We deliberately don't pull in text/template
// because the rendering is straight-line.
//
//go:embed templates/go/*
var templatesFS embed.FS

// writeTemplateGo renders the Go template into dir. Returns an error
// if a target file already exists and force is false.
func writeTemplateGo(dir, slug string, force bool) error {
root := "templates/go"
return fs.WalkDir(templatesFS, root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
rel, err := filepath.Rel(root, path)
if err != nil {
return fmt.Errorf("relativise template path %q: %w", path, err)
}
// Rename foo.tmpl -> foo so a user editing the template
// in-place doesn't accidentally trip Go's build on the
// .tmpl extension. The convention applies to every file
// that benefits from it; today only main.go.tmpl uses it.
target := filepath.Join(dir, strings.TrimSuffix(rel, ".tmpl"))
// gitignore.tmpl becomes .gitignore — the leading-dot
// rename is special-cased because the embedded
// filesystem can't carry dotfiles cleanly.
if filepath.Base(target) == "gitignore" {
target = filepath.Join(filepath.Dir(target), ".gitignore")
}

if !force {
if _, err := os.Stat(target); err == nil {
return fmt.Errorf("file already exists: %s (use --force to overwrite)", target)
}
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return fmt.Errorf("create dir for %q: %w", target, err)
}
data, err := templatesFS.ReadFile(path)
if err != nil {
return fmt.Errorf("read template %q: %w", path, err)
}
rendered := strings.ReplaceAll(string(data), "{{PLUGIN_NAME}}", slug)
rendered = strings.ReplaceAll(rendered, "{{PLUGIN_NAME_LITERAL}}", slug)
if err := os.WriteFile(target, []byte(rendered), 0o644); err != nil {
return fmt.Errorf("write %q: %w", target, err)
}
return nil
})
}

// sanitizeSlug converts a directory basename into a plugin-manifest-
// safe slug: lowercase ASCII, hyphens for non-alphanumerics, no
// leading/trailing hyphens, falling back to "my-plugin" if nothing
// remains.
//
// The host's manifest schema accepts /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
// the sanitiser produces output that always satisfies that regex.
func sanitizeSlug(in string) string {
var b strings.Builder
b.Grow(len(in))
for _, r := range strings.ToLower(in) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
case r == '-', r == '_', r == ' ', r == '.':
b.WriteByte('-')
}
}
out := strings.Trim(b.String(), "-")
for strings.Contains(out, "--") {
out = strings.ReplaceAll(out, "--", "-")
}
if out == "" {
return "my-plugin"
}
return out
}
176 changes: 176 additions & 0 deletions cli/gonext/cmd/plugin/init_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package plugin

import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)

// init_test.go validates the `gonext plugin init` scaffolder: argument
// parsing, template rendering, the slug-substitution pass, and the
// "don't clobber existing files" safety. The render itself is straight
// string-replacement so the tests focus on outcomes (files written,
// substitutions applied) rather than internal mechanics.

func TestRunInitWritesExpectedFiles(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "my-plugin")

var stdout, stderr bytes.Buffer
code := runInit([]string{"--template=go", "--name=acme-hello", target}, &stdout, &stderr)
if code != ExitOK {
t.Fatalf("init failed: code=%d stderr=%s", code, stderr.String())
}

wantFiles := []string{"main.go", "manifest.json", "go.mod", "Makefile", ".gitignore"}
for _, f := range wantFiles {
path := filepath.Join(target, f)
if _, err := os.Stat(path); err != nil {
t.Errorf("expected file %s to exist: %v", f, err)
}
}
}

func TestRunInitSlugSubstitution(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "my-plugin")

var stdout, stderr bytes.Buffer
code := runInit([]string{"--name=acme-hello", target}, &stdout, &stderr)
if code != ExitOK {
t.Fatalf("init failed: code=%d stderr=%s", code, stderr.String())
}

mainBytes, err := os.ReadFile(filepath.Join(target, "main.go"))
if err != nil {
t.Fatalf("read main.go: %v", err)
}
if !strings.Contains(string(mainBytes), `"acme-hello"`) {
t.Errorf("main.go missing slug; first 200 chars: %s", string(mainBytes)[:200])
}
if strings.Contains(string(mainBytes), "{{PLUGIN_NAME}}") {
t.Errorf("main.go still contains unreplaced template token")
}

manifestBytes, err := os.ReadFile(filepath.Join(target, "manifest.json"))
if err != nil {
t.Fatalf("read manifest.json: %v", err)
}
if !strings.Contains(string(manifestBytes), `"name": "acme-hello"`) {
t.Errorf("manifest.json missing slug substitution: %s", manifestBytes)
}
}

func TestRunInitDefaultsToBasename(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "MyPlugin")

var stdout, stderr bytes.Buffer
code := runInit([]string{target}, &stdout, &stderr)
if code != ExitOK {
t.Fatalf("init failed: code=%d stderr=%s", code, stderr.String())
}

manifestBytes, err := os.ReadFile(filepath.Join(target, "manifest.json"))
if err != nil {
t.Fatalf("read manifest.json: %v", err)
}
// MyPlugin -> myplugin (lowercased; no separators to dash).
if !strings.Contains(string(manifestBytes), `"name": "myplugin"`) {
t.Errorf("manifest.json: expected sanitized slug 'myplugin', got: %s", manifestBytes)
}
}

func TestRunInitRefusesToClobber(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "my-plugin")

// First run succeeds.
var stdout, stderr bytes.Buffer
if code := runInit([]string{target}, &stdout, &stderr); code != ExitOK {
t.Fatalf("first init failed: %d %s", code, stderr.String())
}

// Second run without --force fails.
stdout.Reset()
stderr.Reset()
code := runInit([]string{target}, &stdout, &stderr)
if code != ExitFail {
t.Errorf("expected ExitFail on second init, got %d", code)
}
if !strings.Contains(stderr.String(), "already exists") {
t.Errorf("expected 'already exists' in stderr, got: %s", stderr.String())
}

// Third run with --force succeeds.
stdout.Reset()
stderr.Reset()
code = runInit([]string{"--force", target}, &stdout, &stderr)
if code != ExitOK {
t.Errorf("expected ExitOK with --force, got %d: %s", code, stderr.String())
}
}

func TestRunInitUnknownTemplate(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "my-plugin")

var stdout, stderr bytes.Buffer
code := runInit([]string{"--template=rust", target}, &stdout, &stderr)
if code != ExitUsage {
t.Errorf("expected ExitUsage, got %d", code)
}
if !strings.Contains(stderr.String(), "unknown template") {
t.Errorf("expected 'unknown template' in stderr: %s", stderr.String())
}
}

func TestRunInitMissingArg(t *testing.T) {
var stdout, stderr bytes.Buffer
code := runInit([]string{}, &stdout, &stderr)
if code != ExitUsage {
t.Errorf("expected ExitUsage, got %d", code)
}
}

func TestSanitizeSlug(t *testing.T) {
cases := []struct {
in, want string
}{
{"hello", "hello"},
{"Hello", "hello"},
{"my-plugin", "my-plugin"},
{"my_plugin", "my-plugin"},
{"My Plugin", "my-plugin"},
{"hello.world", "hello-world"},
{"---trim---", "trim"},
{"a--b", "a-b"},
{"!@#$%", "my-plugin"},
{"", "my-plugin"},
}
for _, tc := range cases {
got := sanitizeSlug(tc.in)
if got != tc.want {
t.Errorf("sanitizeSlug(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}

func TestPluginInitCommandWired(t *testing.T) {
// Verify that `gonext plugin init` dispatches through the Run
// entry point — guards against accidental removal from the
// switch in plugin.go.
dir := t.TempDir()
target := filepath.Join(dir, "wired-test")

var stdout, stderr bytes.Buffer
code := Run([]string{"init", target}, &stdout, &stderr)
if code != ExitOK {
t.Fatalf("Run init failed: %d %s", code, stderr.String())
}
if _, err := os.Stat(filepath.Join(target, "main.go")); err != nil {
t.Errorf("Run init did not produce main.go: %v", err)
}
}
3 changes: 3 additions & 0 deletions cli/gonext/cmd/plugin/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ func Run(args []string, stdout, stderr io.Writer) int {
return runSign(args[1:], stdout, stderr)
case "diff":
return runDiff(args[1:], stdout, stderr)
case "init":
return runInit(args[1:], stdout, stderr)
default:
fmt.Fprintf(stderr, "gonext plugin: unknown subcommand %q\n\n%s\n", args[0], usage)
return ExitUsage
Expand All @@ -60,6 +62,7 @@ Usage:
gonext plugin <subcommand> [args]

Subcommands:
init Scaffold a new plugin project (Go template via TinyGo).
test Run the plugin contract checks against a bundle.
dev Run the plugin author dev loop (auto-detect, build, upload, watch).
replay Re-run a recorded plugin trap against the currently-loaded bytes.
Expand Down
Loading
Loading