Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,11 @@ Current implemented commands include:
- `basectl update`
- `basectl projects list`
- `basectl activate <project>`
- `basectl test <project>`
- `basectl version`

Planned commands include:

- `basectl test`
- `basectl test <project>`
- `basectl onboard`

The important idea is that the user should not need to memorize a different
Expand Down Expand Up @@ -98,7 +97,6 @@ artifacts:
name: requests
version: latest

# Future contract for `basectl test example`.
test:
command: pytest tests/
```
Expand All @@ -117,9 +115,9 @@ package to Base's hand-curated artifact registry.

Future manifest fields should follow the same rule. A `mise` field causes Base
to run `mise install` from the project root when a project chooses that
substrate. Later, `basectl test` can delegate task execution to `mise run` when
declared. A `test` field should give `basectl test` a single project-owned
command to run. Base should not run arbitrary setup hooks until there is an
substrate. A `test` field gives `basectl test` a single project-owned command
to run. Later, `basectl test` can delegate task execution to `mise run` when
declared. Base should not run arbitrary setup hooks until there is an
explicit, reviewable contract for when they run, where they run, whether they
are interactive, and how dry-run/check/doctor report them.

Expand Down Expand Up @@ -148,6 +146,17 @@ recommended sibling-repo workspace layout. Use `--workspace <path>` to inspect a
different workspace root. Output is tab-separated as `<project-name><TAB><path>`.
Use `--format json` for machine-readable output.

Run a discovered project's declared test command with:

```bash
basectl test example
```

Base runs the manifest `test.command` from the project root, exports
`BASE_PROJECT`, `BASE_PROJECT_ROOT`, `BASE_PROJECT_MANIFEST`, and
`BASE_PROJECT_VENV_DIR`, prepends the project virtual environment when it
exists, and returns the command's exit status.

Once a project is discoverable, activate it with:

```bash
Expand Down
12 changes: 8 additions & 4 deletions cli/bash/commands/basectl/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ that delegate to `basectl`.
- `config`
- `doctor`
- `gh`
- `onboard`
- `test`
- `update-profile`
- `update`
- `projects list`
Expand All @@ -38,7 +40,7 @@ that delegate to `basectl`.

## Planned subcommands

- `onboard`
- Additional `test` delegation modes such as `mise run test`.

## Notes

Expand All @@ -60,12 +62,14 @@ that delegate to `basectl`.
provided, project manifest artifacts with suggested fixes.
- `basectl gh` manages GitHub issues, pull requests, branch naming, and
repository hygiene using Base's opinionated workflow.
- `basectl onboard` guides first-run setup around existing setup, check,
doctor, profile, and project-discovery primitives. See
`docs/basectl-onboard.md`.
- `basectl test <project>` runs the project's manifest `test.command` from the
project root with Base project environment variables exported.
- `basectl update-profile` creates or refreshes managed sections in Bash and Zsh dotfiles.
- `basectl update` updates the Base repository from Git and then runs `basectl setup`.
- `basectl projects list` scans a workspace for `base_manifest.yaml` files and prints discovered project names and paths.
- `basectl version` prints the installed Base version from the repo-root `VERSION` file.
- `basectl onboard` runs a guided first-run checklist around existing setup,
check, doctor, profile, and project-discovery primitives. See
`docs/basectl-onboard.md`.
- basectl-specific bootstrap subcommands live under `cli/bash/commands/basectl/subcommands/`.
- basectl tests live under `cli/bash/commands/basectl/tests/`.
8 changes: 8 additions & 0 deletions cli/bash/commands/basectl/basectl.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ Commands:
Install and bootstrap the local Base CLI environment on macOS.
check [project] [options]
Verify the local Base CLI environment and optional project artifacts without making changes.
test <project> [options]
Run a project's declared test command.
clean [--older-than <age>] [--keep-last <count>] [options]
Remove old Base CLI runtime logs, temp files, and cache entries.
config <path|show|doctor>
Expand Down Expand Up @@ -158,6 +160,11 @@ basectl_do_check() {
base_check_subcommand_main "$@"
}

basectl_do_test() {
basectl_source_subcommand_module test || return 1
base_test_subcommand_main "$@"
}

basectl_do_clean() {
basectl_source_subcommand_module clean || return 1
base_clean_subcommand_main "$@"
Expand Down Expand Up @@ -296,6 +303,7 @@ basectl_main() {
case "$command" in
activate) basectl_do_activate "$@" ;;
check) basectl_do_check "$@" ;;
test) basectl_do_test "$@" ;;
clean) basectl_do_clean "$@" ;;
config) basectl_do_config "$@" ;;
doctor) basectl_do_doctor "$@" ;;
Expand Down
107 changes: 107 additions & 0 deletions cli/bash/commands/basectl/subcommands/test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
#!/usr/bin/env bash

[[ -n "${_base_test_subcommand_sourced:-}" ]] && return
_base_test_subcommand_sourced=1
readonly _base_test_subcommand_sourced

base_test_subcommand_usage() {
cat <<'EOF'
Usage:
basectl test <project> [options]

Options:
--workspace <path> Workspace directory to scan. Defaults to BASE_HOME's parent.
-v Enable DEBUG logging for this subcommand.
-h, --help Show this help text.

Run a project's declared test command from its project root.
EOF
}

base_test_usage_error() {
base_test_subcommand_usage >&2
printf 'ERROR: %s\n' "$*" >&2
return 2
}

base_test_project_venv_dir() {
local project="$1"

if [[ -n "${BASE_PROJECT_VENV_DIR:-}" ]]; then
printf '%s\n' "$BASE_PROJECT_VENV_DIR"
return 0
fi

printf '%s\n' "$HOME/.base.d/$project/.venv"
}

base_test_subcommand_main() {
local project="" wrapper resolve_output resolved_name project_root manifest_path test_command venv_dir
local args=()

while (($#)); do
case "$1" in
-h|--help|help)
base_test_subcommand_usage
return 0
;;
-v)
args+=(--debug)
shift
;;
--workspace)
[[ -n "${2:-}" ]] || {
base_test_usage_error "Option '--workspace' requires an argument."
return $?
}
args+=(--workspace "$2")
shift 2
;;
--workspace=*)
args+=("$1")
shift
;;
-*)
base_test_usage_error "Unknown test option '$1'."
return $?
;;
*)
if [[ -n "$project" ]]; then
base_test_usage_error "The 'test' command accepts exactly one project name."
return $?
fi
project="$1"
shift
;;
esac
done

[[ -n "$project" ]] || {
base_test_usage_error "Project name is required."
return $?
}

wrapper="$BASE_HOME/bin/base-wrapper"
[[ -x "$wrapper" ]] || fatal_error "Base Python wrapper '$wrapper' is missing or is not executable."

resolve_output="$("$wrapper" --project base base_projects test-command "$project" "${args[@]}")" || return $?
IFS=$'\t' read -r resolved_name project_root manifest_path test_command <<<"$resolve_output"

[[ -n "$resolved_name" && -n "$project_root" && -n "$manifest_path" && -n "$test_command" ]] || {
fatal_error "Unable to resolve test command for project '$project'."
}

venv_dir="$(base_test_project_venv_dir "$resolved_name")"
export BASE_PROJECT="$resolved_name"
export BASE_PROJECT_ROOT="$project_root"
export BASE_PROJECT_MANIFEST="$manifest_path"
export BASE_PROJECT_VENV_DIR="$venv_dir"

if [[ -d "$venv_dir/bin" ]]; then
PATH="$venv_dir/bin:$PATH"
export PATH
fi

log_info "Running tests for project '$resolved_name': $test_command"
(cd "$project_root" && bash -c "$test_command")
}
60 changes: 60 additions & 0 deletions cli/bash/commands/basectl/tests/basectl.bats
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ run_basectl() {
[[ "$output" == *"activate <project> [options]"* ]]
[[ "$output" == *"setup [options]"* ]]
[[ "$output" == *"check [project] [options]"* ]]
[[ "$output" == *"test <project> [options]"* ]]
[[ "$output" == *"clean [--older-than <age>] [--keep-last <count>] [options]"* ]]
[[ "$output" == *"config <path|show|doctor>"* ]]
[[ "$output" == *"doctor [project] [options]"* ]]
Expand Down Expand Up @@ -790,6 +791,65 @@ EOF
[[ "$output" != *"Traceback"* ]]
}

@test "basectl test runs declared project test command from project root" {
local python_bin="$TEST_HOME/.base.d/base/.venv/bin/python"
local workspace="$TEST_TMPDIR/workspace"
local state_file="$TEST_TMPDIR/test-state"

mkdir -p "$(dirname "$python_bin")" "$workspace/demo" "$TEST_HOME/.base.d/demo/.venv/bin"
cat > "$python_bin" <<'EOF'
#!/usr/bin/env bash
if [[ "${1:-}" == "-m" && "${2:-}" == "base_projects" && "${3:-}" == "test-command" && "${4:-}" == "demo" ]]; then
printf 'demo\t%s\t%s\t%s\n' "${BASE_TEST_PROJECT_ROOT:?}" "${BASE_TEST_PROJECT_ROOT:?}/base_manifest.yaml" 'printf "project=%s\nroot=%s\nmanifest=%s\nvenv=%s\npwd=%s\npath=%s\n" "$BASE_PROJECT" "$BASE_PROJECT_ROOT" "$BASE_PROJECT_MANIFEST" "$BASE_PROJECT_VENV_DIR" "$PWD" "$PATH" > "$BASE_TEST_TEST_STATE"; exit 7'
exit 0
fi
printf 'unexpected test python args: %s\n' "$*" >&2
exit 1
EOF
chmod +x "$python_bin"
touch "$TEST_HOME/.base.d/demo/.venv/bin/pytest"
printf 'project:\n name: demo\ntest:\n command: pytest tests/\nartifacts: []\n' > "$workspace/demo/base_manifest.yaml"
workspace="$(cd "$workspace" && pwd -P)"

run env \
HOME="$TEST_HOME" \
PATH="/usr/bin:/bin:/usr/sbin:/sbin" \
BASE_TEST_PROJECT_ROOT="$workspace/demo" \
BASE_TEST_TEST_STATE="$state_file" \
"$BASE_REPO_ROOT/bin/basectl" test demo

[ "$status" -eq 7 ]
[[ "$(cat "$state_file")" == *"project=demo"* ]]
[[ "$(cat "$state_file")" == *"root=$workspace/demo"* ]]
[[ "$(cat "$state_file")" == *"manifest=$workspace/demo/base_manifest.yaml"* ]]
[[ "$(cat "$state_file")" == *"venv=$TEST_HOME/.base.d/demo/.venv"* ]]
[[ "$(cat "$state_file")" == *"pwd=$workspace/demo"* ]]
[[ "$(cat "$state_file")" == *"path=$TEST_HOME/.base.d/demo/.venv/bin:"* ]]
}

@test "basectl test prints help without requiring the Base Python venv" {
run_basectl test --help

[ "$status" -eq 0 ]
[[ "$output" == *"Usage:"* ]]
[[ "$output" == *"basectl test <project> [options]"* ]]
[[ "$output" == *"--workspace <path>"* ]]
}

@test "basectl test reports invalid arguments as usage errors" {
run_basectl test
[ "$status" -eq 2 ]
[[ "$output" == *"ERROR: Project name is required."* ]]

run_basectl test --workspace
[ "$status" -eq 2 ]
[[ "$output" == *"ERROR: Option '--workspace' requires an argument."* ]]

run_basectl test --unknown demo
[ "$status" -eq 2 ]
[[ "$output" == *"ERROR: Unknown test option '--unknown'."* ]]
}

@test "basectl clean delegates to the Python cleanup layer" {
local python_bin="$TEST_HOME/.base.d/base/.venv/bin/python"

Expand Down
10 changes: 10 additions & 0 deletions cli/bash/commands/basectl/tests/setup.bats
Original file line number Diff line number Diff line change
Expand Up @@ -1520,10 +1520,18 @@ EOF
COMP_CWORD=2; \
_base_basectl_completion; \
printf "doctor_projects=%s\n" "${COMPREPLY[*]}"; \
COMP_WORDS=(basectl test ""); \
COMP_CWORD=2; \
_base_basectl_completion; \
printf "test_projects=%s\n" "${COMPREPLY[*]}"; \
COMP_WORDS=(basectl check --); \
COMP_CWORD=2; \
_base_basectl_completion; \
printf "check_options=%s\n" "${COMPREPLY[*]}"; \
COMP_WORDS=(basectl test demo --); \
COMP_CWORD=3; \
_base_basectl_completion; \
printf "test_options=%s\n" "${COMPREPLY[*]}"; \
COMP_WORDS=(basectl projects list --); \
COMP_CWORD=3; \
_base_basectl_completion; \
Expand All @@ -1543,7 +1551,9 @@ EOF
[[ "$output" == *"activate_options=--workspace --no-cd"* ]]
[[ "$output" == *"check_projects=base demo"* ]]
[[ "$output" == *"doctor_projects=base demo"* ]]
[[ "$output" == *"test_projects=base demo"* ]]
[[ "$output" == *"check_options=--dev --format"* ]]
[[ "$output" == *"test_options=--workspace"* ]]
[[ "$output" == *"projects_options=--workspace --format"* ]]
[[ "$output" == *"onboard_options=--dev --dry-run --yes --no-profile"* ]]
[[ "$output" == *"clean_options=--older-than --keep-last --dry-run"* ]]
Expand Down
28 changes: 27 additions & 1 deletion cli/python/base_projects/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,13 @@ def run(
return manifest_project_command(ctx, project)
if command == "resolve":
return resolve_project_command(ctx, project, workspace)
if command == "test-command":
return test_command_project_command(ctx, project, workspace)

ctx.log.error("Unknown projects command '%s'. Supported commands: list, current, manifest, resolve.", command)
ctx.log.error(
"Unknown projects command '%s'. Supported commands: list, current, manifest, resolve, test-command.",
command,
)
return 2


Expand Down Expand Up @@ -91,6 +96,27 @@ def resolve_project_command(ctx: base_cli.Context, project_name: str | None, wor
return 0


def test_command_project_command(ctx: base_cli.Context, project_name: str | None, workspace: str | None) -> int:
if not project_name:
ctx.log.error("Project name is required.")
return 2

try:
workspace_root = resolve_workspace_root(ctx, workspace)
project = find_project(workspace_root, project_name)
manifest = read_manifest(project.manifest_path)
except (ProjectDiscoveryError, ManifestError) as exc:
ctx.log.error(str(exc))
return 1

if manifest.test is None:
ctx.log.error("Project '%s' does not declare test.command in '%s'.", project.name, project.manifest_path)
return 1

print(f"{project.name}\t{project.root}\t{project.manifest_path}\t{manifest.test.command}")
return 0


def current_project_command(ctx: base_cli.Context) -> int:
manifest_path = discover_manifest(Path.cwd())
if manifest_path is None:
Expand Down
Loading
Loading