Skip to content

Commit 0810e2d

Browse files
authored
feat(cli/extension): add enable and disable subcommands (#200)
Adding disable (and its counterpart enable) also gives users a non-destructive way to temporarily turn off an extension without losing its config entry.
1 parent 96f6470 commit 0810e2d

10 files changed

Lines changed: 598 additions & 11 deletions

File tree

README.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,12 @@ sley manages [SemVer 2.0.0](https://semver.org/) versions using a simple `.versi
5050

5151
## Features
5252

53-
- **Simple** One `.version` file, one source of truth
54-
- **Language-agnostic** Works with Go, Node, Python, Rust, or any stack
55-
- **Plugin system** Extend with built-in or custom plugins
56-
- **Git integration** Auto-tag releases, generate changelogs
57-
- **Monorepo support** Manage multiple modules independently
58-
- **CI/CD ready** Designed for automation pipelines
53+
- **Simple** - One `.version` file, one source of truth
54+
- **Language-agnostic** - Works with Go, Node, Python, Rust, or any stack
55+
- **Plugin system** - Extend with built-in or custom plugins
56+
- **Git integration** - Auto-tag releases, generate changelogs
57+
- **Monorepo support** - Manage multiple modules independently
58+
- **CI/CD ready** - Designed for automation pipelines
5959

6060
## Installation
6161

contrib/extensions/README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,14 @@ sley extension install --path ./contrib/extensions/github-version-sync
4444
# List installed extensions
4545
sley extension list
4646

47-
# Remove an extension
48-
sley extension remove --name commit-validator
47+
# Disable an extension (temporarily turn off without uninstalling)
48+
sley extension disable --name commit-validator
49+
50+
# Re-enable a disabled extension
51+
sley extension enable --name commit-validator
52+
53+
# Uninstall an extension (remove from config, optionally delete files)
54+
sley extension uninstall --name commit-validator
4955
```
5056

5157
## Documentation
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package extension
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"strings"
7+
8+
"github.com/indaco/sley/internal/extensionmgr"
9+
"github.com/indaco/sley/internal/printer"
10+
"github.com/urfave/cli/v3"
11+
)
12+
13+
// disableCmd returns the "disable" subcommand.
14+
func disableCmd() *cli.Command {
15+
return &cli.Command{
16+
Name: "disable",
17+
Usage: "Disable a registered extension",
18+
Flags: []cli.Flag{
19+
&cli.StringFlag{
20+
Name: "name",
21+
Usage: "Name of the extension to disable",
22+
},
23+
},
24+
Action: func(ctx context.Context, cmd *cli.Command) error {
25+
return runExtensionDisable(cmd)
26+
},
27+
}
28+
}
29+
30+
// runExtensionDisable disables a registered extension by setting enabled: false
31+
// in the configuration file. It uses a surgical YAML replacement so that
32+
// comments and formatting are preserved.
33+
func runExtensionDisable(cmd *cli.Command) error {
34+
extensionName := cmd.String("name")
35+
if extensionName == "" {
36+
return fmt.Errorf("please provide an extension name to disable")
37+
}
38+
39+
updater := extensionmgr.NewDefaultConfigUpdater(&extensionmgr.DefaultYAMLMarshaler{})
40+
if err := updater.SetExtensionEnabled(configFilePath, extensionName, false); err != nil {
41+
if strings.Contains(err.Error(), "not found in configuration") {
42+
printer.PrintWarning(fmt.Sprintf("extension %q not found", extensionName))
43+
return nil
44+
}
45+
printer.PrintError(fmt.Sprintf("failed to disable extension: %v", err))
46+
return nil
47+
}
48+
49+
printer.PrintSuccess(fmt.Sprintf("Extension %q disabled.", extensionName))
50+
return nil
51+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package extension
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"strings"
7+
8+
"github.com/indaco/sley/internal/extensionmgr"
9+
"github.com/indaco/sley/internal/printer"
10+
"github.com/urfave/cli/v3"
11+
)
12+
13+
// enableCmd returns the "enable" subcommand.
14+
func enableCmd() *cli.Command {
15+
return &cli.Command{
16+
Name: "enable",
17+
Usage: "Enable a registered extension",
18+
Flags: []cli.Flag{
19+
&cli.StringFlag{
20+
Name: "name",
21+
Usage: "Name of the extension to enable",
22+
},
23+
},
24+
Action: func(ctx context.Context, cmd *cli.Command) error {
25+
return runExtensionEnable(cmd)
26+
},
27+
}
28+
}
29+
30+
// runExtensionEnable enables a registered extension by setting enabled: true
31+
// in the configuration file. It uses a surgical YAML replacement so that
32+
// comments and formatting are preserved.
33+
func runExtensionEnable(cmd *cli.Command) error {
34+
extensionName := cmd.String("name")
35+
if extensionName == "" {
36+
return fmt.Errorf("please provide an extension name to enable")
37+
}
38+
39+
updater := extensionmgr.NewDefaultConfigUpdater(&extensionmgr.DefaultYAMLMarshaler{})
40+
if err := updater.SetExtensionEnabled(configFilePath, extensionName, true); err != nil {
41+
if strings.Contains(err.Error(), "not found in configuration") {
42+
printer.PrintWarning(fmt.Sprintf("extension %q not found", extensionName))
43+
return nil
44+
}
45+
printer.PrintError(fmt.Sprintf("failed to enable extension: %v", err))
46+
return nil
47+
}
48+
49+
printer.PrintSuccess(fmt.Sprintf("Extension %q enabled.", extensionName))
50+
return nil
51+
}

internal/commands/extension/extensioncmd.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ func Run() *cli.Command {
1111
Usage: "Manage extensions for sley",
1212
Commands: []*cli.Command{
1313
installCmd(),
14-
listCmd(),
1514
uninstallCmd(),
15+
enableCmd(),
16+
disableCmd(),
17+
listCmd(),
1618
},
1719
}
1820
}

internal/commands/extension/extensioncmd_test.go

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,172 @@ func TestExtensionUninstallCmd_WriteConfigError(t *testing.T) {
601601
}
602602
}
603603

604+
/* ------------------------------------------------------------------------- */
605+
/* EXTENSION ENABLE COMMAND */
606+
/* ------------------------------------------------------------------------- */
607+
608+
func TestExtensionEnableCmd_Success(t *testing.T) {
609+
tmpDir := t.TempDir()
610+
configPath := filepath.Join(tmpDir, ".sley.yaml")
611+
612+
content := `extensions:
613+
- name: mock-extension
614+
path: /some/path
615+
enabled: false`
616+
if err := os.WriteFile(configPath, []byte(content), 0644); err != nil {
617+
t.Fatalf("failed to write config file: %v", err)
618+
}
619+
620+
cfg := &config.Config{Path: configPath}
621+
appCli := testutils.BuildCLIForTests(cfg.Path, []*cli.Command{Run()})
622+
623+
output, err := testutils.CaptureStdout(func() {
624+
testutils.RunCLITest(t, appCli, []string{
625+
"sley", "extension", "enable", "--name", "mock-extension",
626+
}, tmpDir)
627+
})
628+
if err != nil {
629+
t.Fatalf("CLI run failed: %v", err)
630+
}
631+
632+
expected := `Extension "mock-extension" enabled.`
633+
if !strings.Contains(output, expected) {
634+
t.Errorf("expected output to contain %q, got:\n%s", expected, output)
635+
}
636+
637+
// Verify the extension is now enabled in the config
638+
data, readErr := os.ReadFile(configPath)
639+
if readErr != nil {
640+
t.Fatalf("failed to read config: %v", readErr)
641+
}
642+
if !strings.Contains(string(data), "enabled: true") {
643+
t.Errorf("expected config to contain 'enabled: true', got:\n%s", string(data))
644+
}
645+
}
646+
647+
func TestExtensionEnableCmd_MissingName(t *testing.T) {
648+
if os.Getenv("TEST_EXTENSION_ENABLE_MISSING_NAME") == "1" {
649+
tmp := t.TempDir()
650+
configPath := filepath.Join(tmp, ".sley.yaml")
651+
content := `extensions:
652+
- name: mock-extension
653+
path: /some/path
654+
enabled: false`
655+
if err := os.WriteFile(configPath, []byte(content), 0644); err != nil {
656+
fmt.Fprintln(os.Stderr, "failed to write config:", err)
657+
os.Exit(1)
658+
}
659+
660+
cfg := &config.Config{Path: configPath}
661+
appCli := testutils.BuildCLIForTests(cfg.Path, []*cli.Command{Run()})
662+
663+
err := appCli.Run(context.Background(), []string{
664+
"sley", "extension", "enable",
665+
})
666+
if err != nil {
667+
fmt.Fprintln(os.Stderr, err)
668+
os.Exit(1)
669+
}
670+
os.Exit(0)
671+
}
672+
673+
cmd := exec.Command(os.Args[0], "-test.run=TestExtensionEnableCmd_MissingName")
674+
cmd.Env = append(os.Environ(), "TEST_EXTENSION_ENABLE_MISSING_NAME=1")
675+
output, err := cmd.CombinedOutput()
676+
677+
if err == nil {
678+
t.Fatal("expected non-zero exit status")
679+
}
680+
681+
expected := "please provide an extension name to enable"
682+
if !strings.Contains(string(output), expected) {
683+
t.Errorf("expected output to contain %q, got:\n%s", expected, output)
684+
}
685+
}
686+
687+
/* ------------------------------------------------------------------------- */
688+
/* EXTENSION DISABLE COMMAND */
689+
/* ------------------------------------------------------------------------- */
690+
691+
func TestExtensionDisableCmd_Success(t *testing.T) {
692+
tmpDir := t.TempDir()
693+
configPath := filepath.Join(tmpDir, ".sley.yaml")
694+
695+
content := `extensions:
696+
- name: mock-extension
697+
path: /some/path
698+
enabled: true`
699+
if err := os.WriteFile(configPath, []byte(content), 0644); err != nil {
700+
t.Fatalf("failed to write config file: %v", err)
701+
}
702+
703+
cfg := &config.Config{Path: configPath}
704+
appCli := testutils.BuildCLIForTests(cfg.Path, []*cli.Command{Run()})
705+
706+
output, err := testutils.CaptureStdout(func() {
707+
testutils.RunCLITest(t, appCli, []string{
708+
"sley", "extension", "disable", "--name", "mock-extension",
709+
}, tmpDir)
710+
})
711+
if err != nil {
712+
t.Fatalf("CLI run failed: %v", err)
713+
}
714+
715+
expected := `Extension "mock-extension" disabled.`
716+
if !strings.Contains(output, expected) {
717+
t.Errorf("expected output to contain %q, got:\n%s", expected, output)
718+
}
719+
720+
// Verify the extension is now disabled in the config
721+
data, readErr := os.ReadFile(configPath)
722+
if readErr != nil {
723+
t.Fatalf("failed to read config: %v", readErr)
724+
}
725+
if !strings.Contains(string(data), "enabled: false") {
726+
t.Errorf("expected config to contain 'enabled: false', got:\n%s", string(data))
727+
}
728+
}
729+
730+
func TestExtensionDisableCmd_MissingName(t *testing.T) {
731+
if os.Getenv("TEST_EXTENSION_DISABLE_MISSING_NAME") == "1" {
732+
tmp := t.TempDir()
733+
configPath := filepath.Join(tmp, ".sley.yaml")
734+
content := `extensions:
735+
- name: mock-extension
736+
path: /some/path
737+
enabled: true`
738+
if err := os.WriteFile(configPath, []byte(content), 0644); err != nil {
739+
fmt.Fprintln(os.Stderr, "failed to write config:", err)
740+
os.Exit(1)
741+
}
742+
743+
cfg := &config.Config{Path: configPath}
744+
appCli := testutils.BuildCLIForTests(cfg.Path, []*cli.Command{Run()})
745+
746+
err := appCli.Run(context.Background(), []string{
747+
"sley", "extension", "disable",
748+
})
749+
if err != nil {
750+
fmt.Fprintln(os.Stderr, err)
751+
os.Exit(1)
752+
}
753+
os.Exit(0)
754+
}
755+
756+
cmd := exec.Command(os.Args[0], "-test.run=TestExtensionDisableCmd_MissingName")
757+
cmd.Env = append(os.Environ(), "TEST_EXTENSION_DISABLE_MISSING_NAME=1")
758+
output, err := cmd.CombinedOutput()
759+
760+
if err == nil {
761+
t.Fatal("expected non-zero exit status")
762+
}
763+
764+
expected := "please provide an extension name to disable"
765+
if !strings.Contains(string(output), expected) {
766+
t.Errorf("expected output to contain %q, got:\n%s", expected, output)
767+
}
768+
}
769+
604770
/* ------------------------------------------------------------------------- */
605771
/* EXTENSION INSTALL COMMAND - ADDITIONAL TESTS */
606772
/* ------------------------------------------------------------------------- */

internal/extensionmgr/interfaces.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ type YAMLMarshaler interface {
1414
type ConfigUpdater interface {
1515
AddExtension(path string, extension config.ExtensionConfig) error
1616
RemoveExtension(path string, extensionName string) error
17+
SetExtensionEnabled(path string, extensionName string, enabled bool) error
1718
}
1819

1920
// ManifestLoader handles loading extension manifests

internal/extensionmgr/mocks.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@ func (m *MockYAMLMarshaler) Marshal(v any) ([]byte, error) {
2222

2323
// MockConfigUpdater is a mock implementation of ConfigUpdater for testing
2424
type MockConfigUpdater struct {
25-
AddExtensionFunc func(path string, extension config.ExtensionConfig) error
26-
RemoveExtensionFunc func(path string, extensionName string) error
25+
AddExtensionFunc func(path string, extension config.ExtensionConfig) error
26+
RemoveExtensionFunc func(path string, extensionName string) error
27+
SetExtensionEnabledFunc func(path string, extensionName string, enabled bool) error
2728
}
2829

2930
func (m *MockConfigUpdater) AddExtension(path string, extension config.ExtensionConfig) error {
@@ -40,6 +41,13 @@ func (m *MockConfigUpdater) RemoveExtension(path string, extensionName string) e
4041
return nil
4142
}
4243

44+
func (m *MockConfigUpdater) SetExtensionEnabled(path string, extensionName string, enabled bool) error {
45+
if m.SetExtensionEnabledFunc != nil {
46+
return m.SetExtensionEnabledFunc(path, extensionName, enabled)
47+
}
48+
return nil
49+
}
50+
4351
// MockManifestLoader is a mock implementation of ManifestLoader for testing
4452
type MockManifestLoader struct {
4553
LoadFunc func(path string) (*extensions.ExtensionManifest, error)

0 commit comments

Comments
 (0)