Commit 4bd9d899 authored by Mickaël Desfrênes's avatar Mickaël Desfrênes
Browse files

add ability to run scripts from bundles

parent ca7f924b
Loading
Loading
Loading
Loading
+2 −0
Changes for LISEZMOI.md: 2 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -14,6 +14,8 @@ Ce dossier contient l'implémentation de `climax`, la CLI utilisée pour gérer
- exporter une copie statique du site (`freeze`)
- inspecter les ressources de bundles (`templates-list`, `static-list`)

Voir [`commands.md`](./commands.md) pour la référence complète des commandes avec exemples.

## Périmètre et notes

- Les données de registre des projets sont stockées dans `~/.climax/projects.json`.
+2 −0
Changes for README.md: 2 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -14,6 +14,8 @@ This directory contains the implementation of `climax`, the CLI used to manage [
- export a static site snapshot (`freeze`)
- inspect bundle assets (`templates-list`, `static-list`)

See [`commands.md`](./commands.md) for the full command reference with examples.


## Scope and Notes

+67 −0
Changes for cli.go: 67 added lines, 0 removed lines.
Original line number Diff line number Diff line
package main

import (
	"errors"
	"fmt"
	"strconv"
	"strings"
@@ -34,6 +35,7 @@ func newRootCmd() *cobra.Command {
	cmd.AddCommand(newBundlesRemoveCmd())
	cmd.AddCommand(newFeedCmd())
	cmd.AddCommand(newSyncCmd())
	cmd.AddCommand(newRunCmd())
	cmd.AddCommand(newBasexCmd())
	cmd.AddCommand(newBasexGUICmd())
	cmd.AddCommand(newTemplatesListCmd())
@@ -223,6 +225,71 @@ func newSyncCmd() *cobra.Command {
	return cmd
}

// newRunCmd uses custom parsing because everything after <bundle>/<script> is
// intended for the XQuery script, not for Cobra. Only wrapper options before
// the target are consumed here; script flags are converted to BaseX bindings
// when the command runs.
func newRunCmd() *cobra.Command {
	directory := cwd()
	cmd := &cobra.Command{
		Use:                "run <bundle>/<script> [script args...]",
		Short:              T("cmd.run.short"),
		DisableFlagParsing: true,
		RunE: func(cmd *cobra.Command, args []string) error {
			opts, err := parseRunArgs(args, directory)
			if err != nil {
				return err
			}
			if opts.help {
				renderHelp(cmd, []string{})
				return nil
			}
			return runBundleScript(opts.target, opts.directory, opts.scriptArgs)
		},
	}
	cmd.Flags().StringVar(&directory, "directory", directory, T("flag.common.directory"))
	return cmd
}

type runArgs struct {
	directory  string
	target     string
	help       bool
	scriptArgs []string
}

func parseRunArgs(args []string, directory string) (runArgs, error) {
	opts := runArgs{directory: directory}
	for i := 0; i < len(args); i++ {
		arg := args[i]
		switch {
		case arg == "--":
			if i+1 >= len(args) {
				return opts, errors.New(T("err.run_target_required"))
			}
			opts.target = args[i+1]
			opts.scriptArgs = args[i+2:]
			return opts, nil
		case arg == "--help" || arg == "-h":
			opts.help = true
			return opts, nil
		case arg == "--directory":
			if i+1 >= len(args) {
				return opts, fmt.Errorf(T("err.flag_needs_argument"), "--directory")
			}
			i++
			opts.directory = args[i]
		case strings.HasPrefix(arg, "--directory="):
			opts.directory = strings.TrimPrefix(arg, "--directory=")
		default:
			opts.target = arg
			opts.scriptArgs = args[i+1:]
			return opts, nil
		}
	}
	return opts, errors.New(T("err.run_target_required"))
}

// newBasexCmd is intentionally wired differently from the other climax
// commands: BaseX has its own CLI flags, so Cobra must not parse or reject
// unknown options before Java sees them. Only climax-owned options are handled
+3 −0
Changes for cli_commands_test.go: 3 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -31,6 +31,7 @@ func TestRootRegistersAllCLICommands(t *testing.T) {
		"info",
		"new",
		"projects",
		"run",
		"start",
		"static-list",
		"stop",
@@ -64,6 +65,7 @@ func TestCLICommandsExposeExpectedFlags(t *testing.T) {
		"bundles-remove": {"directory"},
		"feed":           {"directory", "verbose", "yes"},
		"sync":           {"directory"},
		"run":            {"directory"},
		"basex":          {"directory", "verbose"},
		"basex-gui":      {"directory", "verbose"},
		"templates-list": {"directory"},
@@ -128,6 +130,7 @@ func TestHelpRunsForAllCLICommands(t *testing.T) {
			{"bundles-remove", "dummy", "--help"},
			{"feed", "dummy", "--help"},
			{"sync", "--help"},
			{"run", "--help"},
			{"help", "basex"},
			{"basex-gui", "--help"},
			{"templates-list", "--help"},
+97 −0
Changes for commands.go: 97 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -408,6 +408,103 @@ func runSync(directory string) error {
	return errors.New(T("err.no_max_use_new"))
}

func runBundleScript(target, directory string, scriptArgs []string) error {
	abs, _, err := loadProject(directory)
	if err != nil {
		return err
	}
	bundleName, scriptName, err := parseBundleScriptTarget(target)
	if err != nil {
		return err
	}
	bundleDir := filepath.Join(abs, ".max", "basex", "webapp", "max", "bundles", bundleName)
	if !dirExists(bundleDir) || fileExists(filepath.Join(bundleDir, ".ignore")) {
		return fmt.Errorf(T("err.bundle_not_active"), bundleName)
	}
	scriptPath, err := bundleScriptPath(bundleDir, scriptName)
	if err != nil {
		return err
	}
	if !fileExists(scriptPath) {
		return fmt.Errorf(T("err.run_script_not_found"), filepath.ToSlash(filepath.Join(bundleName, scriptName)))
	}

	bindings, err := runScriptBindings(scriptArgs)
	if err != nil {
		return err
	}
	cmdArgs := []string{
		"-u",
		"-b",
		fmt.Sprintf("maxPath=\"%s\"", abs),
	}
	cmdArgs = append(cmdArgs, bindings...)
	cmdArgs = append(cmdArgs, scriptPath)
	cmd, err := newBaseXCommand(abs, "org.basex.BaseX", cmdArgs...)
	if err != nil {
		return err
	}
	return cmd.Run()
}

func parseBundleScriptTarget(target string) (string, string, error) {
	bundleName, scriptName, ok := strings.Cut(strings.TrimSpace(target), "/")
	if !ok || strings.TrimSpace(bundleName) == "" || strings.TrimSpace(scriptName) == "" {
		return "", "", fmt.Errorf(T("err.run_target_format"), target)
	}
	if bundleName != filepath.Base(bundleName) || bundleName == "." || bundleName == ".." {
		return "", "", fmt.Errorf(T("err.run_target_format"), target)
	}
	return bundleName, scriptName, nil
}

func bundleScriptPath(bundleDir, scriptName string) (string, error) {
	scriptsDir := filepath.Join(bundleDir, "scripts")
	scriptRel := filepath.Clean(filepath.FromSlash(strings.TrimSpace(scriptName)))
	if scriptRel == "." || scriptRel == ".." || filepath.IsAbs(scriptRel) || strings.HasPrefix(scriptRel, ".."+string(os.PathSeparator)) {
		return "", fmt.Errorf(T("err.run_target_format"), scriptName)
	}
	if !strings.HasSuffix(strings.ToLower(scriptRel), ".xq") {
		scriptRel += ".xq"
	}
	scriptPath := filepath.Join(scriptsDir, scriptRel)
	rel, err := filepath.Rel(scriptsDir, scriptPath)
	if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
		return "", fmt.Errorf(T("err.run_target_format"), scriptName)
	}
	return scriptPath, nil
}

func runScriptBindings(args []string) ([]string, error) {
	bindings := make([]string, 0, len(args)*2)
	for i := 0; i < len(args); i++ {
		arg := args[i]
		if !strings.HasPrefix(arg, "-") || arg == "-" {
			return nil, fmt.Errorf(T("err.run_param_format"), arg)
		}
		nameValue := strings.TrimLeft(arg, "-")
		name, value, hasValue := strings.Cut(nameValue, "=")
		name = strings.TrimSpace(name)
		if name == "" {
			return nil, fmt.Errorf(T("err.run_param_format"), arg)
		}
		if !hasValue {
			if i+1 < len(args) && !looksLikeRunScriptFlag(args[i+1]) {
				i++
				value = args[i]
			} else {
				value = "true"
			}
		}
		bindings = append(bindings, "-b", fmt.Sprintf("%s=%s", name, value))
	}
	return bindings, nil
}

func looksLikeRunScriptFlag(arg string) bool {
	return strings.HasPrefix(arg, "-") && arg != "-"
}

func runBasex(directory string, verbose bool, args []string) error {
	abs, _, err := loadProject(directory)
	if err != nil {
Loading