Skip to content

Commit 84e1eaa

Browse files
8bitAlexclaude
andauthored
feat: accept plain yaml/yml/json profiles in cloned repos and gists (#74)
* feat: fall back to plain yaml/yml/json when no *.raid.yaml at repo root `raid profile add <git-url>` previously only picked up profile files matching the *.raid.yaml convention (or profile.json). Single-file gists and scratch repos that just contain `profile.yaml` or `myprofile.yml` would clone successfully but then fail with "No profile files found in repository". When the primary patterns (profile.raid.yaml, *.raid.yaml/*.raid.yml, profile.json) match nothing, fall back to any plain .yaml/.yml/.json at the repo root. processProfileFiles validates each candidate against the profile schema, so non-profile YAML in a repo root is still rejected with a clear "Skipping … invalid profile" message rather than misbehaving. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: exit 1 when no profile candidate validated successfully Copilot review feedback on #74: the new plain-yaml fallback in findProfileFilesInDir can now match arbitrary root yamls (e.g. docker-compose.yaml from a gist or scratch repo). Those candidates fail schema validation in processProfileFiles and the function previously returned exit 0 with "No new profiles found" — the same code path used when "all profiles already exist", which masks a real failure and means `raid profile add <url>` would falsely report success. Differentiate the two cases: - queued empty AND existingNames non-empty → "all already exist", exit 0 (unchanged for the ergonomic re-run case). - queued empty AND existingNames empty → "No valid profiles found", exit 1. Also adds a regression test exercising exactly the gist-style scenario flagged in the review: a cloned repo containing only docker-compose.yaml, which now correctly exits 1. Existing tests that assumed schema-invalid input was a soft success (httpURL_invalidProfile, httpURL_unmarshalError, invalidProfileName) updated to assert the new — and more correct — exit-1 behavior. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: cover the fallback loop's directory-skip branch The plain-yaml fallback in findProfileFilesInDir skips subdirectories (only files are profile candidates), but no existing test exercised that branch — the codecov/patch check was flagging it as uncovered. Real-world example: a scratch repo with docs/ and assets/ alongside a profile.yaml at the root. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5f37965 commit 84e1eaa

5 files changed

Lines changed: 149 additions & 17 deletions

File tree

site/docs/usage/profile.mdx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,18 +36,20 @@ Add a profile from a local file:
3636
raid profile add ./my-profile.yaml
3737
```
3838

39-
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:
39+
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:
4040

4141
```bash
4242
raid profile add https://github.com/my-org/raid-profiles
4343
raid profile add git@github.com:my-org/raid-profiles.git
44+
raid profile add https://gist.github.com/<user>/<id> # gist with profile.yaml
4445
```
4546

46-
Add a profile from a raw file URL (HTTP/HTTPS URL ending in `.yaml`, `.yml`, or `.json`):
47+
Add a profile from a raw file URL (HTTP/HTTPS URL ending in `.yaml`, `.yml`, or `.json`) — handy for the gist "Raw" link, where the filename inside the gist doesn't matter:
4748

4849
```bash
4950
raid profile add https://example.com/team-profile.yaml
5051
raid profile add https://raw.githubusercontent.com/my-org/repo/main/team.raid.yaml
52+
raid profile add https://gist.githubusercontent.com/<user>/<id>/raw/<sha>/profile.yaml
5153
```
5254

5355
**URL type detection.** Raid picks the right strategy automatically:

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.10.1 — upcoming
13+
14+
**Plain-yaml profiles via `raid profile add <url>`.** When the URL points at a git repo (or a single-file gist) whose profile file isn't named with the `*.raid.yaml` convention — e.g. just `profile.yaml`, `myprofile.yml`, or `config.json` — raid now picks it up as a fallback after no `*.raid.yaml`/`profile.json` matches are found. Schema validation runs the same way, so non-profile YAML lying around in a repo root is still rejected with a clear "invalid profile" message. Makes ad-hoc gists usable without renaming the file.
15+
1216
## 0.10.0 — upcoming
1317

1418
**Local-only repositories.** `url` is now optional on profile repository entries. Omit it for projects that aren't backed by a git remote — raid skips cloning and runs install tasks against the existing path. The path must already exist on disk; if it doesn't, raid surfaces a clear error instead of trying to `git clone ""`. `raid doctor` no longer flags a missing `.git` directory as a warning for local-only repos. Closes [#71](https://github.com/8bitAlex/raid/issues/71).

src/cmd/profile/fetch.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,12 @@ func addProfilesFromHTTPURL(rawURL string) int {
139139

140140
// findProfileFilesInDir returns profile YAML/JSON files found at the root of dir.
141141
// Priority: profile.raid.yaml/yml first, then any *.raid.yaml/yml, then profile.json.
142+
// Fallback for repositories that don't follow the *.raid.yaml convention
143+
// (single-file gists, scratch repos): if none of the above match, accept any
144+
// plain .yaml/.yml/.json at the root. processProfileFiles validates each
145+
// candidate against the profile schema, so non-profile YAML lying around
146+
// in a repo root produces a clear "Skipping … invalid profile" message
147+
// rather than incorrect behavior.
142148
func findProfileFilesInDir(dir string) []string {
143149
seen := map[string]bool{}
144150
var found []string
@@ -175,6 +181,18 @@ func findProfileFilesInDir(dir string) []string {
175181

176182
add("profile.json")
177183

184+
if len(found) == 0 {
185+
for _, e := range entries {
186+
if e.IsDir() {
187+
continue
188+
}
189+
ext := strings.ToLower(filepath.Ext(e.Name()))
190+
if ext == ".yaml" || ext == ".yml" || ext == ".json" {
191+
add(e.Name())
192+
}
193+
}
194+
}
195+
178196
return found
179197
}
180198

@@ -222,6 +240,17 @@ func processProfileFiles(paths []string) int {
222240
}
223241

224242
if len(queued) == 0 {
243+
// Differentiate "all profiles already registered" (fine, exit 0)
244+
// from "we found candidate files but none validated as profiles"
245+
// (a real failure — exit 1 so callers and CI catch it). The
246+
// fallback in findProfileFilesInDir can pull in plain root yamls
247+
// like docker-compose.yaml from gists/scratch repos; without
248+
// this branch `raid profile add <url>` would exit 0 even though
249+
// nothing was imported.
250+
if len(existingNames) == 0 {
251+
fmt.Println("No valid profiles found")
252+
return 1
253+
}
225254
fmt.Println("No new profiles found")
226255
return 0
227256
}

src/cmd/profile/fetch_test.go

Lines changed: 111 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -143,13 +143,70 @@ func TestFindProfileFilesInDir_noDuplicates(t *testing.T) {
143143
}
144144
}
145145

146-
func TestFindProfileFilesInDir_noRaidYAML_noJSON(t *testing.T) {
146+
func TestFindProfileFilesInDir_plainYAMLFallback(t *testing.T) {
147147
dir := t.TempDir()
148-
// A plain .yaml file should not be picked up.
148+
// Single-file gist scenario: a plain .yaml at the root, no *.raid.yaml.
149+
// Should be picked up as a fallback.
149150
writeRaidYAML(t, dir, "plain.yaml", "p")
150151
got := findProfileFilesInDir(dir)
151-
if len(got) != 0 {
152-
t.Errorf("findProfileFilesInDir plain yaml: got %v, want none", got)
152+
if len(got) != 1 || !strings.HasSuffix(got[0], "plain.yaml") {
153+
t.Errorf("findProfileFilesInDir plain yaml fallback: got %v", got)
154+
}
155+
}
156+
157+
func TestFindProfileFilesInDir_plainYAMLNotPickedUpWhenRaidYAMLExists(t *testing.T) {
158+
dir := t.TempDir()
159+
// When a *.raid.yaml is present, plain .yaml/.yml siblings (e.g. CI
160+
// configs, docker-compose files) must NOT be picked up — the fallback
161+
// only kicks in when there are no primary matches.
162+
writeRaidYAML(t, dir, "profile.raid.yaml", "p")
163+
writeRaidYAML(t, dir, "docker-compose.yaml", "ignored")
164+
writeRaidYAML(t, dir, "ci.yml", "ignored")
165+
got := findProfileFilesInDir(dir)
166+
if len(got) != 1 || !strings.HasSuffix(got[0], "profile.raid.yaml") {
167+
t.Errorf("findProfileFilesInDir: got %v, want only profile.raid.yaml", got)
168+
}
169+
}
170+
171+
func TestFindProfileFilesInDir_multiplePlainYAMLFallback(t *testing.T) {
172+
dir := t.TempDir()
173+
// Multi-file gist: multiple plain yaml files, no *.raid.yaml. All are
174+
// picked up; processProfileFiles will validate each one and skip those
175+
// that aren't valid profiles.
176+
writeRaidYAML(t, dir, "a.yaml", "a")
177+
writeRaidYAML(t, dir, "b.yml", "b")
178+
got := findProfileFilesInDir(dir)
179+
if len(got) != 2 {
180+
t.Errorf("findProfileFilesInDir multi plain: got %d files, want 2", len(got))
181+
}
182+
}
183+
184+
func TestFindProfileFilesInDir_plainJSONFallback(t *testing.T) {
185+
dir := t.TempDir()
186+
// Plain .json file (not literally named profile.json) — picked up via
187+
// the same fallback so a gist with `myprofile.json` works.
188+
path := filepath.Join(dir, "myprofile.json")
189+
if err := os.WriteFile(path, []byte(`{"name":"j"}`), 0644); err != nil {
190+
t.Fatal(err)
191+
}
192+
got := findProfileFilesInDir(dir)
193+
if len(got) != 1 || !strings.HasSuffix(got[0], "myprofile.json") {
194+
t.Errorf("findProfileFilesInDir plain json fallback: got %v", got)
195+
}
196+
}
197+
198+
func TestFindProfileFilesInDir_fallbackSkipsDirectories(t *testing.T) {
199+
dir := t.TempDir()
200+
// Subdirectory at the repo root must be skipped by the fallback loop —
201+
// only files are considered profile candidates. Real-world example: a
202+
// scratch repo with `docs/` and `assets/` plus a single profile.yaml.
203+
if err := os.Mkdir(filepath.Join(dir, "docs"), 0755); err != nil {
204+
t.Fatal(err)
205+
}
206+
writeRaidYAML(t, dir, "profile.yaml", "p")
207+
got := findProfileFilesInDir(dir)
208+
if len(got) != 1 || !strings.HasSuffix(got[0], "profile.yaml") {
209+
t.Errorf("findProfileFilesInDir with subdir: got %v, want only profile.yaml", got)
153210
}
154211
}
155212

@@ -264,6 +321,40 @@ func TestRunAddProfile_gitURL_noProfiles(t *testing.T) {
264321
}
265322
}
266323

324+
// TestRunAddProfile_gitURL_onlyNonProfileYAML covers the regression
325+
// surface introduced by the plain-yaml fallback in findProfileFilesInDir.
326+
// A repo (or single-file gist) whose only root yaml/json files are
327+
// non-profile content (e.g. docker-compose.yaml, package.json) now gets
328+
// pulled in by the fallback, fails schema validation in
329+
// processProfileFiles, and must exit 1 with "No valid profiles found"
330+
// rather than misleadingly succeeding with "No new profiles found".
331+
func TestRunAddProfile_gitURL_onlyNonProfileYAML(t *testing.T) {
332+
setupConfig(t)
333+
defer saveFetchMocks()()
334+
335+
homeDir := t.TempDir()
336+
getHomeDir = func() string { return homeDir }
337+
detectGitURL = func(string) bool { return true }
338+
gitCloneFunc = func(_, dir string) error {
339+
// Plain non-profile yaml at the repo root — matches the fallback
340+
// glob but fails schema validation.
341+
return os.WriteFile(filepath.Join(dir, "docker-compose.yaml"),
342+
[]byte("services:\n web:\n image: nginx\n"), 0644)
343+
}
344+
345+
out := captureStdout(t, func() {
346+
if code := runAddProfile("https://github.com/example/repo"); code != 1 {
347+
t.Errorf("code = %d, want 1", code)
348+
}
349+
})
350+
if !strings.Contains(out, "No valid profiles found") {
351+
t.Errorf("got %q, want 'No valid profiles found'", out)
352+
}
353+
if !strings.Contains(out, "Skipping docker-compose.yaml") {
354+
t.Errorf("got %q, want 'Skipping docker-compose.yaml' diagnostic", out)
355+
}
356+
}
357+
267358
func TestRunAddProfile_httpURL_success(t *testing.T) {
268359
setupConfig(t)
269360
defer saveFetchMocks()()
@@ -318,12 +409,16 @@ func TestRunAddProfile_httpURL_invalidProfile(t *testing.T) {
318409
}
319410

320411
out := captureStdout(t, func() {
321-
if code := runAddProfile("https://example.com/profile.yaml"); code != 0 {
322-
t.Errorf("code = %d, want 0", code)
412+
// Schema-invalid input is a real failure — exit 1, not 0. The
413+
// previous "No new profiles found" exit-0 behavior masked
414+
// failures (especially relevant now that the file-discovery
415+
// fallback can pull in arbitrary plain yaml).
416+
if code := runAddProfile("https://example.com/profile.yaml"); code != 1 {
417+
t.Errorf("code = %d, want 1", code)
323418
}
324419
})
325-
if !strings.Contains(out, "No new profiles found") {
326-
t.Errorf("got %q, want 'No new profiles found'", out)
420+
if !strings.Contains(out, "No valid profiles found") {
421+
t.Errorf("got %q, want 'No valid profiles found'", out)
327422
}
328423
}
329424

@@ -342,12 +437,13 @@ func TestRunAddProfile_httpURL_unmarshalError(t *testing.T) {
342437
proUnmarshal = func(string) ([]pro.Profile, error) { return nil, errMock }
343438

344439
out := captureStdout(t, func() {
345-
if code := runAddProfile("https://example.com/profile.yaml"); code != 0 {
346-
t.Errorf("code = %d, want 0", code)
440+
// Unmarshal failures are real errors — exit 1.
441+
if code := runAddProfile("https://example.com/profile.yaml"); code != 1 {
442+
t.Errorf("code = %d, want 1", code)
347443
}
348444
})
349-
if !strings.Contains(out, "No new profiles found") {
350-
t.Errorf("got %q, want 'No new profiles found'", out)
445+
if !strings.Contains(out, "No valid profiles found") {
446+
t.Errorf("got %q, want 'No valid profiles found'", out)
351447
}
352448
}
353449

@@ -461,8 +557,9 @@ func TestRunAddProfile_invalidProfileName(t *testing.T) {
461557
proContains = func(string) bool { return false }
462558

463559
out := captureStdout(t, func() {
464-
if code := runAddProfile("https://example.com/profile.yaml"); code != 0 {
465-
t.Errorf("code = %d, want 0", code)
560+
// All profiles rejected by ValidateFileName → real failure, exit 1.
561+
if code := runAddProfile("https://example.com/profile.yaml"); code != 1 {
562+
t.Errorf("code = %d, want 1", code)
466563
}
467564
})
468565
if !strings.Contains(out, "invalid name") {

src/resources/app.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
version=0.10.0-beta
1+
version=0.10.1-beta
22
environment=development

0 commit comments

Comments
 (0)