sap-devs doctor — Design Specification
Goal
Add sap-devs doctor so developers can verify their local environment meets the tool version requirements defined in their SAP content packs. Optimised for CI use: exits 1 if any tool fails.
Commands
sap-devs doctor # check all packs
sap-devs doctor --profile cap-dev # check a specific profile's packs
sap-devs doctor --profile @active # check the currently configured profile's packs
sap-devs doctor --fix # same as above + print install commands for failuresContent Schema
Each pack may contain a tools.yaml file. The ToolDef struct already exists in internal/content/pack.go:
type ToolDef struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
Required string `yaml:"required"` // semver constraint e.g. ">=18.0.0", or "latest"
Detect ToolDetect `yaml:"detect"`
Install map[string]string `yaml:"install"` // keys: "windows", "macos", "linux", "all"
Docs string `yaml:"docs"`
}
type ToolDetect struct {
Command string `yaml:"command"` // e.g. "node --version"
Pattern string `yaml:"pattern"` // regex with one capture group for the version
}tools.yaml is already parsed by LoadPack into pack.Tools.
Architecture
What already exists (do not re-implement)
ToolDefandToolDetectstructs — ininternal/content/pack.gotools.yamlloading — inLoadPack(populatespack.Tools)LoadPacks(nil)— loads all packs unfilteredLoadPacks(profile)— loads profile-weighted packs- Profile resolution —
config.LoadProfile+loader.FindProfile(same asinjectandresources)
New code: internal/content/doctor.go
type CheckStatus string
const (
StatusOK CheckStatus = "ok"
StatusFail CheckStatus = "fail"
StatusMissing CheckStatus = "missing"
StatusUnknown CheckStatus = "unknown" // required is "latest", or version parse failed
)
type ToolResult struct {
Tool ToolDef
Status CheckStatus
Found string // raw captured version string as returned by the detect command, empty if missing
}
// Runner abstracts exec.Command for testability.
type Runner func(command string) (string, error)
// CheckTool runs the tool's detect command, extracts version via regex, and
// compares against the required constraint.
func CheckTool(tool ToolDef, run Runner) ToolResult
// CheckTools runs CheckTool for each tool, deduplicating by ID (first seen wins).
func CheckTools(tools []ToolDef, run Runner) []ToolResultCheckTool logic
- Run
tool.Detect.Commandviarun - If error →
ToolResult{Status: StatusMissing} - Apply
tool.Detect.Patternregex to output; capture group 1 is the version - If no match →
ToolResult{Status: StatusMissing} - If
tool.Required == "latest"→ToolResult{Status: StatusUnknown, Found: captured string} - Call
parseConstraint(tool.Required, captured)→ if satisfied →StatusOK, else →StatusFail
parseConstraint
No external semver library is used. Implement a small helper:
// parseConstraint parses a required string of the form ">=1.2.3", ">1.2.3",
// "=1.2.3", "<=1.2.3", or "<1.2.3" and compares it against found.
// Both version strings are normalised before comparison: a leading "v" is stripped,
// then each is zero-padded to exactly three components (major.minor.patch) by the
// caller before being passed to compareVersions. Returns false if either version
// cannot be parsed.
func parseConstraint(required, found string) bool
// compareVersions compares two version strings of exactly three dot-separated
// integer segments and returns -1, 0, or 1. Each segment has any trailing
// non-digit characters stripped before parsing (e.g. "0-alpine3.19" → "0",
// "7 (release)" → "7"), so real-world version strings with suffixes compare
// correctly. Always iterates exactly three positions.
func compareVersions(a, b string) intThe operator is extracted by scanning the prefix for >=, >, <=, <, = (in that order to avoid ambiguity). The remainder is the required version. parseConstraint zero-pads both the required version and found to exactly three .-separated components, strips any leading "v", then calls compareVersions. parseConstraint dispatches on the operator against the return value of compareVersions. If no recognised operator prefix is found (e.g. a bare "18.0.0" with no operator), parseConstraint returns false.
CheckTools deduplication
Tools with the same ID may appear in multiple packs. CheckTools checks each unique ID only once (first occurrence in the slice wins).
New code: cmd/doctor.go
Thin presentation layer only.
Profile resolution
The --profile flag uses the sentinel string "@active" to mean "use the configured profile". Define it as a package-level constant const profileActive = "@active" in cmd/doctor.go so it can be reused if other commands adopt --profile later.
| Flag value | Behaviour |
|---|---|
"" (omitted) | loader.LoadPacks(nil) — all packs |
"@active" | config.LoadProfile → if ID empty: error "no profile set"; loader.FindProfile(id) → if nil: error "profile not found" |
| any other string | loader.FindProfile(value) → if nil: error "profile not found" |
execRunner
execRunner is the default Runner used in production. It splits the command string on spaces: parts[0] is the executable, parts[1:] are arguments passed to exec.Command(parts[0], parts[1:]...). It uses cmd.CombinedOutput() so that tools writing version output to stderr (e.g. some SAP CLI tools) are captured correctly.
Flow
- Resolve packs via profile flag
- Collect all
ToolDefvalues from all packs into a flat slice - Call
content.CheckTools(tools, execRunner) - Print aligned table
- If
--fix, print install commands forStatusFailandStatusMissingresults - If any result is
StatusFailorStatusMissing→ exit 1
Output Format
Table (always printed)
TOOL REQUIRED FOUND STATUS
node >=18.0.0 v20.11.0 ok
@sap/cds-dk >=7.0.0 6.8.2 FAIL
btp-cli latest 3.65.0 ok (unverified)
cf-cli >=8.0.0 - MISSINGFoundcolumn displays the raw captured version string (as returned by detect command), or-if missingStatusOK→okStatusUnknown→ok (unverified)StatusFail→FAILStatusMissing→MISSING
Install commands (--fix only, printed after table)
Install commands:
@sap/cds-dk npm install -g @sap/cds-dk
cf-cli apt-get install cf8-cliInstall commands are printed for StatusFail and StatusMissing results only. StatusUnknown (tools with required: latest) does not trigger install output even when --fix is set, because the tool is present.
Note: A tool with required: latest whose detect command errors or whose output doesn't match the pattern will yield StatusMissing (not StatusUnknown) — it still counts as a failure and triggers the install hint.
Platform is selected from tool.Install using runtime.GOOS:
"windows"→windowskey"darwin"→macoskey"linux"→linuxkey- Falls back to
"all"key if OS-specific key is absent - If
tool.Installis nil/empty, or no matching key exists, prints"see: <tool.Docs>"
Exit Codes
| Condition | Exit code |
|---|---|
All tools ok or ok (unverified) | 0 |
Any tool FAIL or MISSING | 1 |
| Profile not found / config error | 1 |
Dependencies
No new dependencies. Version comparison is implemented with a small parseConstraint helper using only the standard library (strings, strconv).
Error Handling
- Missing
tools.yamlin a pack: already silently skipped byLoadPack - Tool not installed or detect command fails:
StatusMissing— not a Go error - Version string doesn't match pattern: treated as
StatusMissing required: "latest"with tool present:StatusUnknown— not a failurerequired: "latest"with tool missing:StatusMissing— is a failuretool.Installnil or empty map: safe in Go (nil map read returns""); falls through to docs fallback
Testing
Tests in internal/content/doctor_test.go using a fake Runner — no real processes spawned:
TestCheckTool_OK— runner returns"v20.11.0", required">=18.0.0"→StatusOKTestCheckTool_Fail— runner returns"6.8.2", required">=7.0.0"→StatusFailTestCheckTool_Missing— runner returns an error →StatusMissingTestCheckTool_PatternNoMatch— runner returns output with no regex match →StatusMissingTestCheckTool_Latest—required: "latest", runner returns version →StatusUnknownTestCheckTool_LatestMissing—required: "latest", runner returns error →StatusMissingTestCheckTools_Dedup— two tools with same ID → runner called once, not twiceTestParseConstraint_GTE—">=18.0.0"with"18.0.0"→ true; with"17.9.9"→ falseTestParseConstraint_GT—">18.0.0"with"18.0.1"→ true; with"18.0.0"→ falseTestParseConstraint_PartialVersion—">=8"with"8.0.0"→ true;"7.9.9"→ false
Files
- Create:
internal/content/doctor.go—CheckStatus,ToolResult,Runner,CheckTool,CheckTools,parseConstraint,compareVersions - Create:
internal/content/doctor_test.go - Create:
cmd/doctor.go—doctorCobra command with--profileand--fixflags; definesprofileActive = "@active"