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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,17 @@ raid deploy staging v1.2.3 # $RAID_ARG_1=staging, $RAID_ARG_2=v1.2.3

Custom commands appear alongside built-in commands in `raid --help`. Commands defined in a profile take priority over same-named commands from repositories.

### `raid <repo> <command>`

Run a command defined in a specific repository's `raid.yaml`. This is useful when a repo defines a command with the same name as a profile-level command — `raid <repo> <command>` always targets the repo's version.

```bash
raid backend test # run the "test" command from the backend repo
raid frontend build # run the "build" command from the frontend repo
```

Each repository that defines commands appears as a subcommand in `raid --help`. Run `raid <repo> --help` to see available commands for that repo.

---

## Configuration
Expand Down
7 changes: 7 additions & 0 deletions site/docs/examples/custom-commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -200,3 +200,10 @@ raid migrate

raid seed
```

These commands are also accessible explicitly via `raid <repo> <command>`:

```bash
raid api migrate # run the "migrate" command from the api repo
raid api seed # run the "seed" command from the api repo
```
9 changes: 8 additions & 1 deletion site/docs/features/repo-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,14 @@ commands:
cmd: "go test ./..."
```

Command names must be unique across the profile and all repos. They cannot shadow built-in names: `profile`, `install`, `env`, `doctor`.
When a profile-level command has the same name as a repo command, the profile version wins for `raid <name>`. To explicitly run the repo's version, use `raid <repo-name> <command>`:

```bash
raid api api-test # explicitly run the api repo's "api-test" command
raid api api-dev # explicitly run the api repo's "api-dev" command
```

Command names cannot shadow built-in names: `profile`, `install`, `env`, `doctor`, `context`, `help`, `version`, `completion`.

## Environments

Expand Down
15 changes: 14 additions & 1 deletion site/docs/references/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,19 @@ raid test-all

Custom commands are defined in `commands` sections of the profile or in individual repository `raid.yaml` files. Run `raid --help` to see all available commands.

Custom command names cannot shadow built-in names (`profile`, `install`, `env`, `doctor`).
Custom command names cannot shadow built-in or reserved names (`profile`, `install`, `env`, `doctor`, `context`, `help`, `version`, `completion`).

To run a command from a specific repository (e.g. when profiles shadow a repo command with the same name):

```bash
raid <repo-name> <command>
```

```bash
raid backend test # run the "test" command from the backend repo
raid frontend build # run the "build" command from the frontend repo
```

Each repo with commands appears as a subcommand in `raid --help`. Run `raid <repo> --help` to list that repo's commands.

For more details, see [Custom Commands](/docs/usage/custom).
19 changes: 18 additions & 1 deletion site/docs/usage/custom.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,26 @@ commands:

When `out` is omitted entirely, both stdout and stderr are shown. When `out` is present, only streams explicitly set to `true` are displayed. The `file` field writes both stdout and stderr to the specified path regardless of the `stdout`/`stderr` settings.

## Repo-scoped commands

When a profile and a repository define commands with the same name, the profile's version takes priority for `raid <command>`. To explicitly target a specific repository's command, use:

```bash
raid <repo-name> <command>
```

For example, if both the profile and the `backend` repo define a `test` command:

```bash
raid test # runs the profile-level "test"
raid backend test # runs the backend repo's "test"
```

Each repository that defines commands appears as a subcommand in `raid --help`. Run `raid <repo> --help` to see that repo's available commands.

## Constraints

Custom command names cannot shadow built-in names: `profile`, `install`, `env`, `doctor`.
Custom command names cannot shadow reserved built-in CLI names: `profile`, `install`, `env`, `doctor`, `context`, `help`, `version`, `completion`.

## Running tasks in parallel

Expand Down
48 changes: 48 additions & 0 deletions src/cmd/raid.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ func executeRoot(args []string) int {
rootCmd.SilenceErrors = true
rootCmd.SilenceUsage = true
registerUserCommands(rootCmd, cmds)
registerRepoCommands(rootCmd, raid.GetRepos())

// Reset args so cobra parses from our slice rather than os.Args when the
// caller is providing a different args list (e.g. during tests).
Expand Down Expand Up @@ -197,6 +198,53 @@ func registerUserCommands(root *cobra.Command, cmds []lib.Command) {
}
}

// registerRepoCommands adds one subcommand per repository that has commands.
// Each repo subcommand exposes the repo's own commands as sub-subcommands,
// letting users run `raid <repo> <command>` to target a specific repo even
// when a profile-level command has the same name.
func registerRepoCommands(root *cobra.Command, repos []lib.Repo) {
for _, repo := range repos {
if len(repo.Commands) == 0 {
continue
}
if reservedNames[repo.Name] {
fmt.Fprintf(os.Stderr, "warning: repository '%s' conflicts with a built-in subcommand and will not be available as a command namespace\n", repo.Name)
continue
}
if hasCommand(root, repo.Name) {
fmt.Fprintf(os.Stderr, "warning: repository '%s' conflicts with a user command and will not be available as a command namespace\n", repo.Name)
continue
}
repoName := repo.Name
repoCmd := &cobra.Command{
Use: repoName,
Short: fmt.Sprintf("Commands for the %s repository", repoName),
}
for _, cmd := range repo.Commands {
cmdName := cmd.Name
repoCmd.AddCommand(&cobra.Command{
Use: cmdName,
Short: cmd.Usage,
RunE: func(c *cobra.Command, args []string) error {
return raid.WithMutationLock(func() error {
return raid.ExecuteRepoCommand(repoName, cmdName, args)
})
},
})
}
root.AddCommand(repoCmd)
}
}

func hasCommand(root *cobra.Command, name string) bool {
for _, c := range root.Commands() {
if c.Name() == name {
return true
}
}
return false
}

// baseVersion strips "-preview" and anything following it from a version string
// so that preview builds compare correctly against their corresponding release tag.
func baseVersion(version string) string {
Expand Down
131 changes: 131 additions & 0 deletions src/cmd/raid_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -787,3 +787,134 @@ func TestExecute_nonInfoUpdateNotice(t *testing.T) {
// but this exercises both select branches across multiple runs.
_ = executeRoot([]string{"raid", "env", "list"})
}

// --- registerRepoCommands ---

func TestRegisterRepoCommands_emptyRepos(t *testing.T) {
root := newTestRoot()
registerRepoCommands(root, nil)
if len(root.Commands()) != 0 {
t.Errorf("expected no subcommands, got %d", len(root.Commands()))
}
}

func TestRegisterRepoCommands_noCommandsSkipped(t *testing.T) {
root := newTestRoot()
registerRepoCommands(root, []lib.Repo{{Name: "empty"}})
if len(root.Commands()) != 0 {
t.Errorf("expected no subcommands for repo with no commands, got %d", len(root.Commands()))
}
}

func TestRegisterRepoCommands_appearsInHelp(t *testing.T) {
root := newTestRoot()
registerRepoCommands(root, []lib.Repo{{
Name: "backend",
Commands: []lib.Command{{Name: "test", Usage: "Run backend tests"}},
}})

out := helpOutput(root)
if !strings.Contains(out, "backend") {
t.Errorf("help output missing repo name 'backend'\n\nfull output:\n%s", out)
}
}

func TestRegisterRepoCommands_repoSubcommands(t *testing.T) {
root := newTestRoot()
registerRepoCommands(root, []lib.Repo{{
Name: "api",
Commands: []lib.Command{
{Name: "test", Usage: "Run API tests"},
{Name: "migrate", Usage: "Run migrations"},
},
}})

var repoCmd *cobra.Command
for _, c := range root.Commands() {
if c.Name() == "api" {
repoCmd = c
break
}
}
if repoCmd == nil {
t.Fatal("expected 'api' subcommand on root")
}

var buf bytes.Buffer
repoCmd.SetOut(&buf)
repoCmd.SetErr(&buf)
_ = repoCmd.Help()
out := buf.String()
for _, want := range []string{"test", "Run API tests", "migrate", "Run migrations"} {
if !strings.Contains(out, want) {
t.Errorf("repo help missing %q\n\nfull output:\n%s", want, out)
}
}
}

func TestRegisterRepoCommands_reservedNameSkipped(t *testing.T) {
root := newTestRoot()
var buf bytes.Buffer
root.SetErr(&buf)
registerRepoCommands(root, []lib.Repo{{
Name: "install",
Commands: []lib.Command{{Name: "test"}},
}})

if len(root.Commands()) != 0 {
t.Error("repo with reserved name should not be registered")
}
}

func TestRegisterRepoCommands_conflictWithUserCommand(t *testing.T) {
root := newTestRoot()
registerUserCommands(root, []lib.Command{{Name: "deploy", Usage: "Deploy all"}})
registerRepoCommands(root, []lib.Repo{{
Name: "deploy",
Commands: []lib.Command{{Name: "test"}},
}})

count := 0
for _, c := range root.Commands() {
if c.Name() == "deploy" {
count++
}
}
if count != 1 {
t.Errorf("expected exactly 1 'deploy' command (user cmd), got %d", count)
}
}

func TestRegisterRepoCommands_multipleRepos(t *testing.T) {
root := newTestRoot()
registerRepoCommands(root, []lib.Repo{
{Name: "backend", Commands: []lib.Command{{Name: "test"}}},
{Name: "frontend", Commands: []lib.Command{{Name: "build"}}},
})

names := map[string]bool{}
for _, c := range root.Commands() {
names[c.Name()] = true
}
if !names["backend"] || !names["frontend"] {
t.Errorf("expected both 'backend' and 'frontend', got %v", names)
}
}

func TestRegisterRepoCommands_runE(t *testing.T) {
setupTestConfig(t)
root := newTestRoot()
registerRepoCommands(root, []lib.Repo{{
Name: "myrepo",
Commands: []lib.Command{{Name: "testcmd", Usage: "A test command"}},
}})

var buf bytes.Buffer
root.SetOut(&buf)
root.SetErr(&buf)
root.SetArgs([]string{"myrepo", "testcmd"})
err := root.Execute()
if err == nil {
t.Error("expected error with no context, got nil")
}
}
49 changes: 49 additions & 0 deletions src/internal/lib/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ func GetCommands() []Command {
return context.Profile.Commands
}

// GetRepos returns the repositories in the active profile.
func GetRepos() []Repo {
if context == nil {
return nil
}
return context.Profile.Repositories
}

// ExecuteCommand runs the tasks for the named command, applying any output configuration.
// Args are exposed as RAID_ARG_1, RAID_ARG_2, ... environment variables for the duration
// of the command and are unset afterwards.
Expand Down Expand Up @@ -70,6 +78,47 @@ func ExecuteCommand(name string, args []string) error {
return err
}

// ExecuteRepoCommand runs a command defined in a specific repository's raid.yaml.
func ExecuteRepoCommand(repoName, cmdName string, args []string) error {
repos := GetRepos()
var repo *Repo
for i := range repos {
if repos[i].Name == repoName {
repo = &repos[i]
break
}
}
if repo == nil {
return fmt.Errorf("repository '%s' not found", repoName)
}

var found Command
for _, cmd := range repo.Commands {
if cmd.Name == cmdName {
found = cmd
break
}
}
if found.IsZero() {
return fmt.Errorf("command '%s' not found in repository '%s'", cmdName, repoName)
}

clearRaidArgs()
defer clearRaidArgs()
for i, arg := range args {
os.Setenv(fmt.Sprintf("RAID_ARG_%d", i+1), arg)
}

startSession()
defer endSession()

recentName := repoName + ":" + found.Name
startedAt := RecordRecentStart(recentName)
err := runCommand(found)
RecordRecentEnd(recentName, err, startedAt)
return err
}

// clearRaidArgs unsets all RAID_ARG_* environment variables.
func clearRaidArgs() {
for _, kv := range os.Environ() {
Expand Down
Loading
Loading