Improve doctor output and local backend management#53
Conversation
There was a problem hiding this comment.
Pull request overview
This PR improves the Ballast “doctor/help/version” UX across the Go wrapper and the TypeScript/Python/Go backends, adds persisted ballastVersion metadata to .rulesrc.json, and introduces local backend installation/refresh flows (with supporting docs and regression tests).
Changes:
- Add
doctorcommand across wrapper and backend CLIs, with formatted output and recommendations. - Persist and read
ballastVersionin.rulesrc.jsonacross TypeScript/Python/Go. - Add wrapper
install-clisupport for managing local backend binaries under.ballast/, plus docs/test updates and workspace ignore rules.
Reviewed changes
Copilot reviewed 21 out of 23 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| scripts/release-cross-language-check.sh | Adds smoke checks ensuring --help, --version, and doctor execute successfully. |
| packages/ballast-typescript/src/version.ts | Centralizes TypeScript CLI version export from package.json. |
| packages/ballast-typescript/src/install.ts | Persists ballastVersion into saved config on install. |
| packages/ballast-typescript/src/install.test.ts | Updates install expectations to include ballastVersion. |
| packages/ballast-typescript/src/doctor.ts | Introduces TypeScript doctor report collection/formatting. |
| packages/ballast-typescript/src/doctor.test.ts | Adds unit coverage for doctor recommendations and formatting. |
| packages/ballast-typescript/src/config.ts | Adds optional ballastVersion parsing/merging into config load/save. |
| packages/ballast-typescript/src/config.test.ts | Adds tests for reading/writing ballastVersion. |
| packages/ballast-typescript/src/cli.ts | Adds doctor command, exports helpers for testing, and uses shared version constant. |
| packages/ballast-typescript/src/cli.test.ts | Adds parseArgs coverage for help/version/doctor. |
| packages/ballast-python/uv.lock | Bumps Python package version to 5.0.2. |
| packages/ballast-python/tests/test_cli.py | Adds tests for doctor output and ballastVersion presence in config writes. |
| packages/ballast-python/ballast/cli.py | Adds Python doctor functionality and persists ballastVersion in config. |
| packages/ballast-go/cmd/ballast-go/main_test.go | Adds Go backend tests for help/version/doctor and config ballastVersion behavior. |
| packages/ballast-go/cmd/ballast-go/main.go | Adds Go backend doctor command/reporting and ballastVersion field in config. |
| docs/installation.md | Documents wrapper doctor/install-cli and refresh-config behavior. |
| cli/ballast/main_test.go | Adds wrapper tests for doctor/install-cli/refresh-config and local backend execution paths. |
| cli/ballast/main.go | Implements wrapper doctor/install-cli flows and local .ballast/ backend resolution/installation. |
| README.md | Updates wrapper examples and documents wrapper command surface. |
| AGENTS.md | Adds “Command Sync” guidelines to keep help/docs/tests aligned with CLI surface changes. |
| .rulesrc.json | Adds ballastVersion to repo config. |
| .gitignore | Ignores local .ballast/ and .ci/ workspace directories. |
| .claude/rules/common/local-dev-license.md | Updates license guidance snippet and clarifies precedence with AGENTS.md. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const pathCheck = spawnSync('bash', ['-lc', `command -v ${name}`], { | ||
| encoding: 'utf8' | ||
| }); | ||
| if (pathCheck.status !== 0) { | ||
| return { name, version: null, path: null }; | ||
| } | ||
| const cliPath = pathCheck.stdout.trim(); | ||
| const versionCheck = spawnSync(name, ['--version'], { encoding: 'utf8' }); | ||
| if (versionCheck.status !== 0) { | ||
| return { name, version: null, path: cliPath }; | ||
| } |
There was a problem hiding this comment.
detectInstalledCli shells out to bash -lc "command -v ..." to locate executables. This hard-requires bash and won’t work on Windows or minimal environments where bash isn’t present. Prefer a cross-platform approach (e.g., attempt spawnSync(name, ['--version']) and treat ENOENT as not found, or use process.platform to choose where vs command -v, or a small PATH-search helper) so doctor works everywhere the TypeScript CLI supports.
| const pathCheck = spawnSync('bash', ['-lc', `command -v ${name}`], { | |
| encoding: 'utf8' | |
| }); | |
| if (pathCheck.status !== 0) { | |
| return { name, version: null, path: null }; | |
| } | |
| const cliPath = pathCheck.stdout.trim(); | |
| const versionCheck = spawnSync(name, ['--version'], { encoding: 'utf8' }); | |
| if (versionCheck.status !== 0) { | |
| return { name, version: null, path: cliPath }; | |
| } | |
| const isWindows = process.platform === 'win32'; | |
| const pathArgs = isWindows ? [name] : ['-v', name]; | |
| const pathCmd = isWindows ? 'where' : 'command'; | |
| const pathCheck = spawnSync(pathCmd, pathArgs, { encoding: 'utf8' }); | |
| if ( | |
| pathCheck.error?.code === 'ENOENT' || | |
| pathCheck.status !== 0 || | |
| !pathCheck.stdout | |
| ) { | |
| return { name, version: null, path: null }; | |
| } | |
| const cliPath = pathCheck.stdout | |
| .split(/\r?\n/) | |
| .map((line) => line.trim()) | |
| .filter((line) => line.length > 0)[0] ?? null; | |
| if (!cliPath) { | |
| return { name, version: null, path: null }; | |
| } | |
| const versionCheck = spawnSync(name, ['--version'], { encoding: 'utf8' }); | |
| if ( | |
| versionCheck.error?.code === 'ENOENT' || | |
| versionCheck.status !== 0 || | |
| !versionCheck.stdout | |
| ) { | |
| return { name, version: null, path: cliPath }; | |
| } |
There was a problem hiding this comment.
Tracked separately in #61. I did not fold native Windows support into this PR because it needs a cross-platform executable lookup change and dedicated test coverage beyond the scope of the current doctor/install fixes.
| if (arg === '--version' || arg === '-v') { | ||
| return { version: true }; | ||
| } | ||
| if (arg === 'doctor') { | ||
| return { doctor: true }; | ||
| } |
There was a problem hiding this comment.
parseArgs returns { doctor: true } if it encounters the literal token doctor anywhere in argv, even when the actual command is install. That means ballast-typescript install ... doctor ... (or any future flag value equal to doctor) would unexpectedly run doctor instead of install. Consider only treating doctor as a top-level command when it is the first positional command (e.g., argv[2]), and keep the install option parser focused on install flags.
There was a problem hiding this comment.
Fixed in 2dfadce. parseArgs now only returns { doctor: true } when doctor is the top-level command, and I added a regression test to confirm later install arguments with the literal value doctor are treated as normal install input.
| if item.Version != "" && compareVersions(item.Version, targetVersion) < 0 { | ||
| needsCLIFix = true |
There was a problem hiding this comment.
In buildDoctorReport, an installed CLI whose --version check failed ends up with Path set but Version empty (shown as unknown). The current logic only marks needsCLIFix when the CLI is missing or when a known version is older, so it won’t recommend doctor --fix for the unknown version case. Align with the TypeScript/Python doctor behavior by treating an empty/unknown version as needing a CLI fix.
| if item.Version != "" && compareVersions(item.Version, targetVersion) < 0 { | |
| needsCLIFix = true | |
| if item.Version == "" { | |
| needsCLIFix = true | |
| } else if compareVersions(item.Version, targetVersion) < 0 { | |
| needsCLIFix = true |
There was a problem hiding this comment.
Fixed in 2dfadce. Go doctor now treats an empty/unknown installed CLI version as needing repair, and I added a test covering the unknown-version recommendation path.
cli/ballast/main.go
Outdated
| installCommand: func(version string, projectRoot string) []string { | ||
| binDir := filepath.Join(projectRoot, ".ballast", "bin") | ||
| toolDir := filepath.Join(projectRoot, ".ballast", "tools", "python") | ||
| release := releaseVersion(version) | ||
| if release == "" { | ||
| return []string{"uv", "tool", "install", "--reinstall", "ballast-python"} | ||
| if sourceRoot := localSourceRoot(); sourceRoot != "" { | ||
| return []string{"env", "UV_TOOL_DIR=" + toolDir, "UV_TOOL_BIN_DIR=" + binDir, "uv", "tool", "install", "--reinstall", filepath.Join(sourceRoot, "packages", "ballast-python")} | ||
| } | ||
| release = releaseVersion(resolveVersion()) | ||
| } | ||
| wheel := fmt.Sprintf( | ||
| "https://github.com/everydaydevopsio/ballast/releases/download/v%s/ballast_python-%s-py3-none-any.whl", | ||
| release, | ||
| release, | ||
| ) | ||
| return []string{"uv", "tool", "install", "--reinstall", "--from", wheel, "ballast-python"} | ||
| return []string{"env", "UV_TOOL_DIR=" + toolDir, "UV_TOOL_BIN_DIR=" + binDir, "uv", "tool", "install", "--reinstall", "--from", wheel, "ballast-python"} |
There was a problem hiding this comment.
In the Python backend installer, when version resolves to a non-release value (e.g. dev) and localSourceRoot() is empty, release stays empty and the code still constructs a GitHub wheel URL with v%s/%s, producing an invalid download URL. This can break ballast doctor --fix and ensureInstalled() for dev wrapper builds outside the ballast source tree. Handle the release == "" case explicitly (e.g., fall back to uv tool install --reinstall ballast-python / latest release, or return an error explaining that a release version is required when not running from source).
There was a problem hiding this comment.
Fixed in 2dfadce. The wrapper no longer constructs an invalid Python wheel URL for dev builds outside the ballast source tree. The installer interface now returns an explicit error, and the Python path reports that a release version or a ballast source checkout is required.
| func installCLIs(selectedLanguage language, version string) int { | ||
| root := findProjectRoot("") | ||
| var languagesToInstall []language | ||
| if selectedLanguage != "" { | ||
| languagesToInstall = []language{selectedLanguage} | ||
| } else { | ||
| languagesToInstall = slices.Clone(supportedLanguages) | ||
| } | ||
|
|
||
| for _, lang := range languagesToInstall { | ||
| tool, ok := toolsByLanguage[lang] | ||
| if !ok { | ||
| fmt.Printf("Unsupported language: %s\n", lang) | ||
| return 1 | ||
| } | ||
| command := tool.installCommand(version, root) | ||
| if len(command) == 0 { | ||
| fmt.Printf("No installer configured for %s\n", lang) | ||
| return 1 | ||
| } | ||
| if err := runCommandFunc(command[0], command[1:]); err != nil { | ||
| fmt.Printf("failed to install %s: %v\n", tool.binary, err) | ||
| return 1 | ||
| } | ||
| } |
There was a problem hiding this comment.
installCLIs runs installers that assume .ballast/bin (and sometimes .ballast/tools/...) already exists (notably go install with GOBIN=... will fail if the directory doesn’t exist). ensureInstalled creates .ballast/bin, but install-cli/doctor --fix call installCLIs directly and skip that setup. Create the required .ballast directories (at least .ballast/bin) before executing installer commands.
There was a problem hiding this comment.
Fixed in 2dfadce. installCLIs now creates the local .ballast/bin and .ballast/tools directories before invoking installers, and there is a wrapper test covering the directory creation path.
Summary
ballast doctoroutput.rulesrc.jsonballastVersionsupport, doctor checks, wrapper install/refresh flows, and regression coverage across Go, Python, and TypeScript.ballast/and.ci/workspace directories and include the remaining requested workspace updatesTesting
go test ./...incli/ballastpackages/ballast-typescript,packages/ballast-python,packages/ballast-go, andcli/ballastduringgit push