Skip to content

Commit 4b778df

Browse files
authored
fix: all High + Medium pre-v1.0 review findings (18 fixes) (#97)
1 parent 54c851b commit 4b778df

29 files changed

Lines changed: 1361 additions & 295 deletions

site/docs/whats-new.mdx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,42 @@ 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.17.2 — upcoming
13+
14+
Eighteen High and Medium pre-v1.0 review fixes. No new features; behavior changes are surgical and called out individually below.
15+
16+
**Concurrent task output stays line-atomic.** Six auxiliary writes in the task runner (`continueOnFailure` warnings, `showExeTime` markers, `Wait` / `Retry` banners, `Prompt` / `Confirm` text) now serialize through the same `outputMu` as the per-task prefix writer. Previously these could interleave mid-line with a peer concurrent task's prefixed output. `Prompt` / `Confirm` release the mutex before the blocking stdin read so concurrent output isn't frozen during user input.
17+
18+
**`raid deploy version` no longer gets misclassified as `raid version`.** `isInfoCommand` now only matches the first non-flag positional against `help` / `version` / `completion`. A user command that takes one of those words as a positional value no longer triggers info-mode's 1.5s version-check wait + spurious "update available" banner.
19+
20+
**`raid profile add` / `remove` / `create` / set-active honor `--json`.** All four subcommands previously printed prose unconditionally; `--json` consumers got unparseable output. The new shapes follow the existing per-command JSON contract (`profile add``{action, path, profiles[], existing[], active}`; `profile remove``{removed[], errors[]}`; `profile` set-active → `{action, name, path}`). Cobra's `RunE` replaces `Run`, so every failure flows through the root structured-error handler and gets a category-correct exit code (`PROFILE_NOT_FOUND` → 5, `PROFILE_INVALID` → 2, `CLONE_FAILED` → 4, etc.) instead of always exiting 1.
21+
22+
**Deterministic ordering in `profile list`.** Output sorted by name in both text and JSON modes — matching the MCP `raid_list_profiles` tool's existing behavior. `env list` is unchanged (kept in YAML-declaration order for author intent).
23+
24+
**Profile / repo env merging matches `mergeCommands` semantics.** In single-repo mode, environments declared on both the wrapping profile and the repo's `raid.yaml` no longer duplicate. Profile wins on name conflict, same contract used for commands. Removes the silent "duplicate env entries in `ListEnvs`" failure mode where `getEnv` returned the first while `ExecuteEnv` wrote variables from whichever happened first in the slice.
25+
26+
**`runCommand`'s `out:` redirection routes through `SetCommandOutput`.** The pre-fix path mutated the package-level `commandStdout` / `commandStderr` globals directly, bypassing the documented swap entry point. With MCP capturing output via `SetCommandOutput`, a `out: {file: ...}` command whose lifetime overlapped with another MCP tool call had a race window. Both paths now go through the same atomic swap + restore.
27+
28+
**`raid doctor` no longer short-circuits on profile schema failure.** A schema error in the wrapping profile previously stopped the doctor report cold, hiding repo-level and verify-block findings. The schema failure is still recorded as an error finding, but doctor now continues into repo checks so the user sees the full health picture in one pass (matching the existing `checkVerify` contract).
29+
30+
**Telemetry consent prompt uses `term.IsTerminal` instead of `os.ModeCharDevice`.** Agent hosts that wire stdin to `/dev/null` (also a character device) no longer get misclassified as interactive — the prompt is suppressed and consent stays at the "not decided" zero value as intended. Matches the same TTY check the output-prefix code already uses.
31+
32+
**Profile name lookups are case-insensitive end-to-end.** Viper stores map keys lowercased, so a profile registered as `MyProfile` lived under `myprofile` while lookups in `ContainsProfile` / `RemoveProfile` / `GetProfile().Path` used the original case — leading to "profile not found" errors right after a successful add. All lookups now normalize before indexing.
33+
34+
**Multi-document profile YAML uses `yaml.NewDecoder`.** Replaces the literal-string `strings.Split(data, "---")` with the proper streaming decoder. Profile YAML containing `---` as content (e.g. inside a `usage:` description or a multi-line `message:`) no longer gets torn at the substring. The schema validator already used `NewDecoder` — extraction now matches.
35+
36+
**Goroutine panic recovery in clone fan-out and `ExecuteTasks`.** A `CloneRepository` panic could strand the install semaphore and deadlock the parent `wg.Wait()`. A panic inside a concurrent task goroutine took down the whole raid process (and the MCP server's long-lived stdio session). Both paths now `defer recover()` and surface the panic as a structured `INTERNAL` error to the aggregate handler.
37+
38+
**Shared reserved-key list across error JSON emitters.** `EmitJSON` (CLI `--json` errors) and `mcpStructuredError` (MCP tool-result errors) previously hand-maintained their own reserved-key lists. They now both consult `errs.IsReservedErrorKey`, so a future error code adding a new envelope field automatically propagates everywhere.
39+
40+
**`raid_list_repos` MCP path caches git state for 1 second.** Each call previously forked N `git rev-parse` + `git status` subprocesses synchronously. Agents that poll the resource at sub-second intervals no longer hammer git; human-interactive `raid context` calls still see fresh state within 1s.
41+
42+
**`parseEnvLines` fast-path on unchanged env.** Consecutive Shell tasks that don't touch the env produce byte-identical dumps. An FNV-1a hash of the dump lets `updateSessionFromEnv` skip the parse + diff entirely on a cache hit — meaningful for commands with many Shell tasks and a static env baseline.
43+
44+
**`applyConfigFlag` warns instead of silently dropping bad values.** `raid --config -mypath …` no longer silently drops `-mypath` (and falls through to cobra's "flag needs an argument" later). A diagnostic on stderr explains the cause so the user can correct the invocation.
45+
46+
**Other:** stdin-buffer behavior between the telemetry prompt and the first `Prompt`/`Confirm` task documented (no real-world impact); `runCommand`'s exe-time emission matrix vs. `out: {stdout, stderr, file}` documented inline.
47+
1248
## 0.17.1 — upcoming
1349

1450
Three pre-v1.0 hardening fixes from the release-readiness review. No behavior change for happy paths; all three are correctness/security bugs with regression tests now in place.

src/cmd/context/serve.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -597,8 +597,12 @@ func mcpStructuredError(toolName string, err error, output string) *mcp.CallTool
597597
payload["hint"] = hint
598598
}
599599
for k, v := range rErr.Details() {
600-
switch k {
601-
case "tool", "code", "message", "category", "hint", "output":
600+
// Shared envelope keys (code/message/category/hint) plus the
601+
// two MCP-local additions (tool, output). The shared half
602+
// comes from errs.IsReservedErrorKey so a future field added
603+
// to the EmitJSON envelope automatically gets respected
604+
// here, not silently emitted under the wrong shape.
605+
if errs.IsReservedErrorKey(k) || k == "tool" || k == "output" {
602606
continue
603607
}
604608
payload[k] = v

src/cmd/profile/add.go

Lines changed: 148 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77

88
"github.com/8bitalex/raid/src/internal/sys"
99
"github.com/8bitalex/raid/src/raid"
10+
"github.com/8bitalex/raid/src/raid/errs"
1011
pro "github.com/8bitalex/raid/src/raid/profile"
1112
"github.com/spf13/cobra"
1213
)
@@ -22,6 +23,71 @@ var (
2223
proSet = pro.Set
2324
)
2425

26+
// addResult is the JSON shape emitted under --json for `raid profile add`.
27+
// Stable contract; new fields ship additively. `Active` is the name of the
28+
// profile that's now active (set to the first newly-added profile when no
29+
// profile was previously active; otherwise unchanged from before the add).
30+
type addResult struct {
31+
Action string `json:"action"` // "added" | "skipped"
32+
Path string `json:"path"`
33+
Profiles []string `json:"profiles,omitempty"`
34+
Existing []string `json:"existing,omitempty"`
35+
Active string `json:"active,omitempty"`
36+
}
37+
38+
// runAddProfile is the legacy exit-code shim for tests that observe
39+
// the int return and assert on stdout text. New tests should drive
40+
// AddProfileCmd through cobra and assert on the returned structured
41+
// error / JSON envelope.
42+
//
43+
// To keep the existing assertions stable, this shim mimics the old
44+
// text-mode output: prints the error message to stdout and returns
45+
// exit code 1. The cobra entry point (AddProfileCmd.RunE) is the
46+
// real public surface and still emits category-correct exit codes +
47+
// structured-error JSON; this shim only papers over the test-facing
48+
// signature.
49+
func runAddProfile(path string) int {
50+
if err := runAddProfileE(AddProfileCmd, path); err != nil {
51+
fmt.Println(legacyErrPrefix(err))
52+
return 1
53+
}
54+
return 0
55+
}
56+
57+
// legacyErrPrefix converts a structured error to its old prose
58+
// representation. Mirrors the strings the previous prose-printing
59+
// flow emitted ("Failed to clone repository: …", "Invalid Profile:
60+
// …", etc.) so tests asserting on exact phrasing keep working.
61+
func legacyErrPrefix(err error) string {
62+
if rErr, ok := errs.AsError(err); ok {
63+
switch rErr.Code() {
64+
case errs.CodeCloneFailed:
65+
return "Failed to clone repository: " + rErr.Error()
66+
case errs.CodeProfileFileMissing:
67+
return "File does not exist: " + rErr.Error()
68+
case errs.CodeProfileInvalid:
69+
// raid.yaml repo-config failures get an "Invalid raid.yaml"
70+
// prefix; profile-schema failures stay generic "Invalid
71+
// Profile". The wrapping cause string contains the marker
72+
// when the failure originated from synthesizeRepo, so a
73+
// substring check is enough.
74+
msg := rErr.Error()
75+
if strings.Contains(msg, "invalid raid.yaml") {
76+
return "Invalid raid.yaml: " + msg
77+
}
78+
return "Invalid Profile: " + msg
79+
case errs.CodeProfileFileRead:
80+
return "Failed to extract profiles: " + rErr.Error()
81+
case errs.CodeTaskHTTPFailed:
82+
return "Failed to download profile: " + rErr.Error()
83+
case errs.CodeConfigInvalid:
84+
return "Failed to save profiles: " + rErr.Error()
85+
}
86+
return rErr.Error()
87+
}
88+
return err.Error()
89+
}
90+
2591
var AddProfileCmd = &cobra.Command{
2692
Use: "add <path|url>",
2793
Short: "Add profile(s) from a local file or URL",
@@ -41,103 +107,139 @@ Raw file URL (HTTP/HTTPS URL ending in .yaml, .yml, or .json):
41107
42108
Profiles from URLs are saved to ~/<name>.raid.yaml before registration.`,
43109
Args: cobra.ExactArgs(1),
44-
Run: func(cmd *cobra.Command, args []string) {
45-
code := runAddProfile(args[0])
46-
if code != 0 {
47-
osExit(code)
48-
}
110+
RunE: func(cmd *cobra.Command, args []string) error {
111+
return runAddProfileE(cmd, args[0])
49112
},
50113
}
51114

52-
// runAddProfile performs the add-profile flow and returns an exit code.
53-
// Extracted from AddProfileCmd.Run so tests can observe the exit code
54-
// without os.Exit terminating the test process.
55-
func runAddProfile(path string) int {
115+
// runAddProfileE is the structured-errors + JSON-aware add-profile flow.
116+
// Every failure produces a structured errs.Error so the root handler can
117+
// emit the right exit-code category and JSON envelope.
118+
func runAddProfileE(cmd *cobra.Command, path string) error {
56119
if isURL(path) {
57-
return runAddProfileFromURL(path)
120+
return runAddProfileFromURLE(cmd, path)
58121
}
59122
path = sys.ExpandPath(path)
60123

61124
if !sys.FileExists(path) {
62-
fmt.Printf("File '%s' does not exist\n", path)
63-
return 1
125+
return errs.ProfileFileMissing(path)
64126
}
65127

66-
var profiles []pro.Profile
128+
profiles, err := extractProfilesFromLocalFile(path)
129+
if err != nil {
130+
return err
131+
}
132+
133+
return registerAddedProfiles(cmd, path, profiles)
134+
}
135+
136+
// extractProfilesFromLocalFile reads + validates the file at path and
137+
// returns the parsed profiles. Splits the "is this a raid.yaml repo
138+
// config or a profile YAML?" choice out of the main flow so the URL
139+
// path can reuse the same logic for downloaded files.
140+
func extractProfilesFromLocalFile(path string) ([]pro.Profile, error) {
67141
if filepath.Base(path) == raid.RaidConfigFileName {
68142
// Files named raid.yaml are repo configs by convention. Validate
69143
// against the repo schema directly so a missing `branch` etc.
70144
// surfaces as a repo-schema error rather than a misleading
71145
// "Invalid Profile" message.
72146
single, serr := proSynthesizeRepo(path)
73147
if serr != nil {
74-
fmt.Printf("Invalid raid.yaml: %v\n", serr)
75-
return 1
148+
return nil, errs.ProfileInvalid(path, fmt.Errorf("invalid raid.yaml: %v", serr))
76149
}
77-
profiles = []pro.Profile{single}
78-
} else if err := proValidate(path); err != nil {
150+
return []pro.Profile{single}, nil
151+
}
152+
if err := proValidate(path); err != nil {
79153
// Non-raid.yaml file failed profile-schema validation. Try the
80154
// repo schema as a fallback so callers can still register a
81155
// renamed repo config; otherwise report the profile error.
82156
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
157+
return []pro.Profile{single}, nil
93158
}
94-
profiles = extracted
159+
return nil, errs.ProfileInvalid(path, err)
95160
}
161+
extracted, err := proUnmarshal(path)
162+
if err != nil {
163+
return nil, errs.ProfileFileRead(path, err)
164+
}
165+
return extracted, nil
166+
}
96167

168+
// registerAddedProfiles partitions parsed profiles into new vs. already-
169+
// registered, writes the new ones under the mutation lock, and emits
170+
// the user-facing result (text or JSON). Used by both the local-file
171+
// and URL paths.
172+
func registerAddedProfiles(cmd *cobra.Command, path string, profiles []pro.Profile) error {
97173
var newProfiles []pro.Profile
98174
var existingNames []string
99175
for _, profile := range profiles {
100-
if exists := proContains(profile.Name); exists {
176+
if proContains(profile.Name) {
101177
existingNames = append(existingNames, profile.Name)
102178
} else {
103179
newProfiles = append(newProfiles, profile)
104180
}
105181
}
106182

107-
if len(existingNames) > 0 {
108-
fmt.Printf("Profiles already exist with names:\n\t%s\n\n", strings.Join(existingNames, ",\n\t"))
109-
}
183+
out := cmd.OutOrStdout()
184+
json := jsonMode(cmd)
110185

111186
if len(newProfiles) == 0 {
112-
fmt.Printf("No new profiles found in %s\n", path)
113-
return 0
187+
// Nothing new to register — not an error, just a no-op.
188+
if json {
189+
return emitJSON(cmd, addResult{Action: "skipped", Path: path, Existing: existingNames})
190+
}
191+
if len(existingNames) > 0 {
192+
fmt.Fprintf(out, "Profiles already exist with names:\n\t%s\n\n", strings.Join(existingNames, ",\n\t"))
193+
}
194+
fmt.Fprintf(out, "No new profiles found in %s\n", path)
195+
return nil
114196
}
115197

198+
var activeAfter string
116199
writeErr := raid.WithMutationLock(func() error {
117200
if err := proAddAll(newProfiles); err != nil {
118-
return fmt.Errorf("save: %w", err)
201+
return err
119202
}
120203
if proGet().IsZero() {
121204
if err := proSet(newProfiles[0].Name); err != nil {
122-
return fmt.Errorf("set active: %w", err)
205+
return err
123206
}
124-
fmt.Printf("Profile '%s' set as active\n", newProfiles[0].Name)
207+
activeAfter = newProfiles[0].Name
208+
} else {
209+
activeAfter = proGet().Name
125210
}
126211
return nil
127212
})
128213
if writeErr != nil {
129-
fmt.Printf("Failed to save profiles: %v\n", writeErr)
130-
return 1
214+
return errs.ConfigInvalid(writeErr)
215+
}
216+
217+
addedNames := make([]string, 0, len(newProfiles))
218+
for _, p := range newProfiles {
219+
addedNames = append(addedNames, p.Name)
220+
}
221+
222+
if json {
223+
return emitJSON(cmd, addResult{
224+
Action: "added",
225+
Path: path,
226+
Profiles: addedNames,
227+
Existing: existingNames,
228+
Active: activeAfter,
229+
})
131230
}
132231

232+
if len(existingNames) > 0 {
233+
fmt.Fprintf(out, "Profiles already exist with names:\n\t%s\n\n", strings.Join(existingNames, ",\n\t"))
234+
}
235+
if activeAfter == newProfiles[0].Name && len(existingNames) == 0 {
236+
// Single newly-active profile path is the common case for first-time users.
237+
fmt.Fprintf(out, "Profile '%s' set as active\n", activeAfter)
238+
}
133239
if len(newProfiles) == 1 {
134-
fmt.Printf("Profile '%s' has been successfully added from %s\n", newProfiles[0].Name, path)
240+
fmt.Fprintf(out, "Profile '%s' has been successfully added from %s\n", newProfiles[0].Name, path)
135241
} else {
136-
names := make([]string, 0, len(newProfiles))
137-
for _, profile := range newProfiles {
138-
names = append(names, profile.Name)
139-
}
140-
fmt.Printf("Profiles:\n\t%s\nhave been successfully added from %s\n", strings.Join(names, ",\n\t"), path)
242+
fmt.Fprintf(out, "Profiles:\n\t%s\nhave been successfully added from %s\n", strings.Join(addedNames, ",\n\t"), path)
141243
}
142-
return 0
244+
return nil
143245
}

0 commit comments

Comments
 (0)