Skip to content

Commit 8357916

Browse files
8bitAlexmeeseeks-botCopilot
authored
feat: support single-repo profiles via raid.yaml (closes #52) (#79)
* feat: support single-repo profiles using raid.yaml configuration * fix: address Copilot review — repo-config detection in profile add - Detect raid.yaml by basename and validate against the repo schema directly, so a missing `branch` surfaces as "Invalid raid.yaml" instead of a misleading "Invalid Profile" message. - Drop the redundant ValidateRepoConfig call; SynthesizeFromRepoConfig already validates, so the previous flow validated repo configs twice (loading embedded schemas on each call). - Keep a non-raid.yaml fallback path so renamed repo configs still work. Co-Authored-By: Copilot <copilot@github.com> * fix: address Copilot review — BuildSingleRepoProfile path + name guards - Enforce that BuildSingleRepoProfile's path argument is named raid.yaml up front. ExtractRepo reads <dir>/raid.yaml unconditionally, so a caller passing a renamed or symlinked file would otherwise validate one file and load another. - In buildProfile, refuse to load a single-repo profile whose registered name no longer matches the raid.yaml's current `name:` field. Active- profile detection compares against the registered key, so silently swapping in a different name breaks `profile list` and friends. - Reword the empty-name error to "missing or empty name" since the check fires on `name: ""` as well as a missing key. Co-Authored-By: Copilot <copilot@github.com> * test: cover lines flagged by Codecov patch check Exercises the new code paths added in the previous two Copilot-fix commits: - BuildSingleRepoProfile rejects a non-raid.yaml basename. - buildProfile rejects (and accepts) registered-name vs current-name in single-repo mode. - runAddProfile reports "Invalid raid.yaml" when a raid.yaml fails the repo schema. - runAddProfile falls through to "Invalid Profile" when a renamed file fails profile validation and also fails the basename guard. --------- Co-authored-by: meeseeks-bot <noreply@openclaw.local> Co-authored-by: Copilot <copilot@github.com>
1 parent ff29f6e commit 8357916

18 files changed

Lines changed: 607 additions & 31 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ go.work.sum
2727
# env file
2828
.env
2929

30+
# user-specific dev profile (this repo self-registers via its raid.yaml)
31+
/profile.raid.yml
32+
/profile.raid.yaml
33+
3034
# AI assistant files
3135
.claude/
3236

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Build: `go build -o raid .` Test: `go test ./...` Run: `go run . <cmd>`
44

55
Layout: main.go→src/cmd. src/cmd/raid.go=root cmd+subcommand registration+version check. Reserved built-in subcmds: context/, doctor/, env/, install/, profile/ (user cmds w/ same name ignored w/ warning). context/ has subcmd serve (MCP stdio server). src/raid/=core domain (profile loading, env resolution, cmd execution). src/internal/=lib/ (shared types), sys/ (OS helpers, GitHub release checks), utils/. schemas/=JSON schemas (raid-repo.schema.json, raid-profile.schema.json, raid-defs.schema.json). src/resources/=embedded assets (app.properties, profile-template, repo-template) via go:embed; resources.go exposes them. site/=Docusaurus source (merged from docsite-source 2026-04-10); builds to gh-pages via .github/workflows/docs.yml on site/** changes.
66

7-
Config: raid.yaml=per-repo (environments+tasks: Shell|Script|HTTP|Wait|Template|Group|Git|Prompt|Confirm|Set|Print). profile.raid.yml=user profile (tracked repos, global settings).
7+
Config: raid.yaml=per-repo (environments+tasks: Shell|Script|HTTP|Wait|Template|Group|Git|Prompt|Confirm|Set|Print). profile.raid.yml=user profile (tracked repos, global settings). This repo dogfoods single-repo-profile mode (#52): no profile.raid.yml is committed; raid.yaml is registered directly with `raid profile add ./raid.yaml` and activated via `raid profile raid`. profile.raid.yml is in .gitignore so local dev profiles never accidentally get committed.
88

99
Versioning: version in src/resources/app.properties. Bump second position (minor) for feature/large changes; bump third position (patch) for small changes or bug fixes.
1010

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -601,7 +601,7 @@ Profiles are registered in a [Viper](https://github.com/spf13/viper)-managed con
601601

602602
### Can I use Raid with a monorepo?
603603

604-
Raid is optimized for multi-repo setups, but it works for a single repo too — you can skip the top-level profile and commit a `raid.yaml` at the repo root to define commands, environments, and tasks for that project.
604+
Raid is optimized for multi-repo setups, but it works for a single repo too — commit a `raid.yaml` at the repo root and register it as a single-repo profile with `raid profile add ./raid.yaml`. No wrapping profile file required; the profile is named after the raid.yaml's `name` field and behaves like any other profile.
605605

606606
### How do I share profiles across my team?
607607

llms.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ Raid is written in Go, distributed as a single self-contained binary, and publis
2828

2929
- [raid install](https://raidcli.dev/docs/usage/install): Clone repositories and run install tasks across your profile
3030
- [raid env](https://raidcli.dev/docs/usage/env): Switch between development, staging, and production environments
31-
- [raid profile](https://raidcli.dev/docs/usage/profile): Add profiles from a git repo URL, raw file URL, or local path; list, switch, and remove profiles
31+
- [raid profile](https://raidcli.dev/docs/usage/profile): Add profiles from a git repo URL, raw file URL, or local path (including single-repo `raid.yaml` configs); list, switch, and remove profiles
3232
- [Custom commands](https://raidcli.dev/docs/usage/custom): Define and invoke `raid <cmd>` team workflows
3333
- [raid doctor](https://raidcli.dev/docs/usage/doctor): Diagnose profile and repo configuration issues
3434
- [raid root command](https://raidcli.dev/docs/usage/raid): Global flags and top-level invocation

profile.raid.yml

Lines changed: 0 additions & 11 deletions
This file was deleted.

site/docs/features/profiles.mdx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,18 @@ environments:
256256

257257
```bash
258258
raid profile add ./my-profile.yaml # register a profile file
259+
raid profile add ./raid.yaml # register a repo config as a single-repo profile
259260
raid profile list # list all registered profiles
260261
raid my-team # switch to the 'my-team' profile
261262
raid profile remove my-team # remove a profile
262263
```
264+
265+
## Single-repo profiles
266+
267+
For projects that ship only a `raid.yaml` (no wrapping profile file), point `raid profile add` straight at the repo config:
268+
269+
```bash
270+
raid profile add ./raid.yaml
271+
```
272+
273+
Raid detects the repo schema and registers it as a single-repo profile named after the raid.yaml's `name` field. Switch to it with `raid profile <name>` like any other profile. The commands, environments, and install tasks declared in the raid.yaml become available at the top level.

site/docs/usage/profile.mdx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ Add a profile from a local file:
3636
raid profile add ./my-profile.yaml
3737
```
3838

39+
Add a repo config (`raid.yaml`) as a single-repo profile — useful for projects that ship only a `raid.yaml` without a wrapping profile file. The profile is named after the raid.yaml's `name` field:
40+
41+
```bash
42+
raid profile add ./raid.yaml
43+
raid profile <name> # activate using the raid.yaml's name
44+
```
45+
3946
Add a profile from a git repository — raid shallow-clones it and imports `*.raid.yaml`, `*.raid.yml`, and `profile.json` files found at the root. If none of those match, raid falls back to any plain `.yaml` / `.yml` / `.json` at the root, so a single-file gist (or scratch repo) with `profile.yaml` or `myprofile.yml` works without renaming:
4047

4148
```bash

site/docs/whats-new.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ description: Feature-by-feature release notes for Raid.
99

1010
User-visible changes per release, latest first. For full commit history see the [GitHub releases page](https://github.com/8bitalex/raid/releases).
1111

12+
## 0.12.0 — upcoming
13+
14+
**Repo-as-a-profile for single-repo projects.** `raid profile add ./raid.yaml` now accepts a repo config directly — no wrapping profile file required. Raid detects the repo schema, registers the file as a single-repo profile named after the raid.yaml's `name` field, and `raid profile <name>` activates it like any other profile. Commands, environments, and install tasks defined in the raid.yaml become available at the top level. Existing multi-repo profiles continue to work unchanged. Closes [#52](https://github.com/8bitAlex/raid/issues/52).
15+
1216
## 0.11.1 — upcoming
1317

1418
**Schemas published at a stable URL.** The profile, repo, and shared-defs JSON Schemas are now served at `https://raidcli.dev/schema/v1/raid-profile.schema.json`, `…/raid-repo.schema.json`, and `…/raid-defs.schema.json`. Generated profile and repo templates point at these URLs out of the box, so YAML LSPs and agents get autocomplete and inline validation against the canonical contract. The `/v1/` path is a stability promise — additive changes only; breaking changes will publish to `/v2/`. The docsite build copies `schemas/` into `static/schema/v1/`, so the canonical source remains the single Go-embedded copy. Closes [#48](https://github.com/8bitAlex/raid/issues/48).

src/cmd/profile/add.go

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package profile
22

33
import (
44
"fmt"
5+
"path/filepath"
56
"strings"
67

78
"github.com/8bitalex/raid/src/internal/sys"
@@ -12,20 +13,24 @@ import (
1213

1314
// Injectable profile-package functions for testing error paths.
1415
var (
15-
proValidate = pro.Validate
16-
proUnmarshal = pro.Unmarshal
17-
proContains = pro.Contains
18-
proAddAll = pro.AddAll
19-
proGet = pro.Get
20-
proSet = pro.Set
16+
proValidate = pro.Validate
17+
proSynthesizeRepo = pro.SynthesizeFromRepoConfig
18+
proUnmarshal = pro.Unmarshal
19+
proContains = pro.Contains
20+
proAddAll = pro.AddAll
21+
proGet = pro.Get
22+
proSet = pro.Set
2123
)
2224

2325
var AddProfileCmd = &cobra.Command{
2426
Use: "add <path|url>",
2527
Short: "Add profile(s) from a local file or URL",
2628
Long: `Add one or more profiles from a local file, a git repository URL, or a raw file URL.
2729
28-
Local path: the file is validated and registered directly.
30+
Local path: the file is validated and registered directly. A repo config
31+
(raid.yaml) is also accepted and registered as a single-repo profile named
32+
after the raid.yaml's ` + "`name`" + ` field — handy for projects that ship
33+
only a raid.yaml without a wrapping profile.
2934
3035
Git URL (git@ prefix, .git suffix, or any HTTP URL that responds to git ls-remote):
3136
raid shallow-clones the repo and imports *.raid.yaml, *.raid.yml, and profile.json
@@ -58,15 +63,35 @@ func runAddProfile(path string) int {
5863
return 1
5964
}
6065

61-
if err := proValidate(path); err != nil {
62-
fmt.Printf("Invalid Profile: %v\n", err)
63-
return 1
64-
}
65-
66-
profiles, err := proUnmarshal(path)
67-
if err != nil {
68-
fmt.Printf("Failed to extract profiles: %v\n", err)
69-
return 1
66+
var profiles []pro.Profile
67+
if filepath.Base(path) == raid.RaidConfigFileName {
68+
// Files named raid.yaml are repo configs by convention. Validate
69+
// against the repo schema directly so a missing `branch` etc.
70+
// surfaces as a repo-schema error rather than a misleading
71+
// "Invalid Profile" message.
72+
single, serr := proSynthesizeRepo(path)
73+
if serr != nil {
74+
fmt.Printf("Invalid raid.yaml: %v\n", serr)
75+
return 1
76+
}
77+
profiles = []pro.Profile{single}
78+
} else if err := proValidate(path); err != nil {
79+
// Non-raid.yaml file failed profile-schema validation. Try the
80+
// repo schema as a fallback so callers can still register a
81+
// renamed repo config; otherwise report the profile error.
82+
if single, serr := proSynthesizeRepo(path); serr == nil {
83+
profiles = []pro.Profile{single}
84+
} else {
85+
fmt.Printf("Invalid Profile: %v\n", err)
86+
return 1
87+
}
88+
} else {
89+
extracted, err := proUnmarshal(path)
90+
if err != nil {
91+
fmt.Printf("Failed to extract profiles: %v\n", err)
92+
return 1
93+
}
94+
profiles = extracted
7095
}
7196

7297
var newProfiles []pro.Profile

src/cmd/profile/profile_test.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -697,6 +697,50 @@ func TestRunAddProfile_allDuplicates(t *testing.T) {
697697
})
698698
}
699699

700+
// TestRunAddProfile_repoConfigAsProfile checks that a raid.yaml (repo schema)
701+
// can be registered as a single-repo profile. The profile name is the
702+
// raid.yaml's `name` field.
703+
func TestRunAddProfile_repoConfigAsProfile(t *testing.T) {
704+
setupConfig(t)
705+
dir := t.TempDir()
706+
repoYaml := filepath.Join(dir, "raid.yaml")
707+
if err := os.WriteFile(repoYaml, []byte("name: soloproj\nbranch: main\n"), 0644); err != nil {
708+
t.Fatal(err)
709+
}
710+
out := captureStdout(t, func() {
711+
if code := runAddProfile(repoYaml); code != 0 {
712+
t.Errorf("runAddProfile repo-config: code = %d, want 0", code)
713+
}
714+
})
715+
if !strings.Contains(out, "soloproj") {
716+
t.Errorf("runAddProfile repo-config: got %q, want 'soloproj'", out)
717+
}
718+
if !lib.ContainsProfile("soloproj") {
719+
t.Error("runAddProfile repo-config: profile not registered")
720+
}
721+
got := lib.GetProfile()
722+
if got.Name != "soloproj" || got.Path != repoYaml {
723+
t.Errorf("active profile = %+v, want name=soloproj path=%s", got, repoYaml)
724+
}
725+
}
726+
727+
// TestRunAddProfile_repoConfigMissingName: a raid.yaml that passes the repo
728+
// schema check but has no name field should report an error.
729+
func TestRunAddProfile_repoConfigMissingName(t *testing.T) {
730+
setupConfig(t)
731+
dir := t.TempDir()
732+
repoYaml := filepath.Join(dir, "raid.yaml")
733+
// Schema-valid (name is a string, just empty) and branch required.
734+
if err := os.WriteFile(repoYaml, []byte("name: \"\"\nbranch: main\n"), 0644); err != nil {
735+
t.Fatal(err)
736+
}
737+
_ = captureStdout(t, func() {
738+
if code := runAddProfile(repoYaml); code != 1 {
739+
t.Errorf("runAddProfile empty-name: code = %d, want 1", code)
740+
}
741+
})
742+
}
743+
700744
func TestRunAddProfile_multiDocSuccess(t *testing.T) {
701745
setupConfig(t)
702746
dir := t.TempDir()
@@ -1128,3 +1172,47 @@ func TestRunCreateWizardCore_withReposAndConfigCreate(t *testing.T) {
11281172
t.Error("proCreateRepoConfigs should have been called")
11291173
}
11301174
}
1175+
1176+
// TestRunAddProfile_repoConfigInvalidSchema covers the new "raid.yaml fails
1177+
// repo-schema validation" path: when basename is raid.yaml we report it as
1178+
// an invalid raid.yaml, not as an invalid profile.
1179+
func TestRunAddProfile_repoConfigInvalidSchema(t *testing.T) {
1180+
setupConfig(t)
1181+
dir := t.TempDir()
1182+
repoYaml := filepath.Join(dir, "raid.yaml")
1183+
// Missing required `branch` — repo schema should reject this.
1184+
if err := os.WriteFile(repoYaml, []byte("name: noBranch\n"), 0644); err != nil {
1185+
t.Fatal(err)
1186+
}
1187+
out := captureStdout(t, func() {
1188+
if code := runAddProfile(repoYaml); code != 1 {
1189+
t.Errorf("runAddProfile invalid raid.yaml: code = %d, want 1", code)
1190+
}
1191+
})
1192+
if !strings.Contains(out, "Invalid raid.yaml") {
1193+
t.Errorf("expected 'Invalid raid.yaml' in output, got %q", out)
1194+
}
1195+
}
1196+
1197+
// TestRunAddProfile_nonRaidYamlFallbackFailsBasenameGuard covers the
1198+
// non-raid.yaml branch where profile validation fails and the fallback
1199+
// synthesis fails BuildSingleRepoProfile's basename guard — the original
1200+
// profile-validation error is the one reported to the user.
1201+
func TestRunAddProfile_nonRaidYamlFallbackFailsBasenameGuard(t *testing.T) {
1202+
setupConfig(t)
1203+
dir := t.TempDir()
1204+
path := filepath.Join(dir, "renamed.raid.yaml")
1205+
// Contents look like a repo config (has `branch`), so profile schema
1206+
// rejects them. Basename guard then rejects the repo-schema fallback.
1207+
if err := os.WriteFile(path, []byte("name: renamed\nbranch: main\n"), 0644); err != nil {
1208+
t.Fatal(err)
1209+
}
1210+
out := captureStdout(t, func() {
1211+
if code := runAddProfile(path); code != 1 {
1212+
t.Errorf("runAddProfile non-raid.yaml repo content: code = %d, want 1", code)
1213+
}
1214+
})
1215+
if !strings.Contains(out, "Invalid Profile") {
1216+
t.Errorf("expected 'Invalid Profile' in output, got %q", out)
1217+
}
1218+
}

0 commit comments

Comments
 (0)