diff --git a/cli/bash/commands/basectl/basectl.sh b/cli/bash/commands/basectl/basectl.sh index b5f361ec..51b97b1f 100644 --- a/cli/bash/commands/basectl/basectl.sh +++ b/cli/bash/commands/basectl/basectl.sh @@ -121,6 +121,7 @@ Wrapper options: --debug-wrapper Enable DEBUG logging before the Base runtime is loaded. --verbose-wrapper Enable verbose runtime argument handling before dispatch. --utc-wrapper Print wrapper/runtime log timestamps in UTC. + --keep-temp Preserve temporary run files after the command completes. --color Preserve color-aware wrapper argument handling. Notes: @@ -131,12 +132,14 @@ Notes: Base rejects `--option=value` syntax before command delegation. Arguments after `--` belong to the delegated project command. - Use `-v` for command-level debug logs. Python package standard options such - as `--debug`, `--quiet`, `--log-file`, `--config`, `--environment`, and - `--keep-temp` are not public `basectl` options. + as `--debug`, `--quiet`, `--log-file`, `--config`, and `--environment` are + not public `basectl` options. - Invoking `basectl` with no command starts a Base runtime shell for the nearest project manifest above the current directory, preserving that directory. If no manifest is found, it falls back to project `base`. In non-interactive shells it prints this help text. + - Use `--keep-temp` before the command name to preserve the complete run + temporary tree; temporary files are removed by default. - Use `--debug-wrapper` when debugging startup before command dispatch or Base runtime initialization. EOF @@ -643,6 +646,9 @@ basectl_finalize_run_bundle() { "${BASE_CLI_HISTORY_STARTED_AT:-}" \ "$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || true)" >"$tmp_file" && mv -f "$tmp_file" "$run_root/run.json" chmod 600 "$run_root/run.json" "${BASE_CLI_PRIMARY_LOG:-$run_root/logs/primary.log}" || return 1 + if [[ "${BASE_CLI_KEEP_TEMP:-}" != true ]]; then + rm -rf -- "$run_root/tmp" + fi } basectl_history_record() { @@ -682,7 +688,12 @@ basectl_history_record() { basectl_main() { - local base_debug=0 command="" command_status run_bundle_enabled=1 + local base_debug=0 keep_temp=0 command="" command_status run_bundle_enabled=1 + + while [[ "${1:-}" == --keep-temp ]]; do + keep_temp=1 + shift + done local history_args=() history_scope="${BASE_CLI_HISTORY_SCOPE:-primary}" local opt @@ -752,6 +763,11 @@ basectl_main() { done shift $((OPTIND - 1)) + while [[ "${1:-}" == --keep-temp ]]; do + keep_temp=1 + shift + done + command="${1:-}" [[ -n "$command" ]] && shift history_args=("$@") @@ -762,6 +778,9 @@ basectl_main() { basectl_history_recordable_command "$command" || run_bundle_enabled=0 basectl_get_base_home || return 1 + if ((keep_temp)); then + export BASE_CLI_KEEP_TEMP=true + fi if ((run_bundle_enabled)); then basectl_initialize_run_bundle "$command" "$@" || return 1 fi diff --git a/cli/bash/commands/basectl/tests/help.bats b/cli/bash/commands/basectl/tests/help.bats index 62aacfb6..6bfd4e70 100644 --- a/cli/bash/commands/basectl/tests/help.bats +++ b/cli/bash/commands/basectl/tests/help.bats @@ -41,6 +41,7 @@ load ./basectl_helpers.bash [[ "$output" == *"--debug-wrapper"* ]] [[ "$output" == *"--verbose-wrapper"* ]] [[ "$output" == *"--utc-wrapper"* ]] + [[ "$output" == *"--keep-temp"* ]] [[ "$output" == *"--color"* ]] } diff --git a/cli/bash/commands/basectl/tests/runtime-dispatch.bats b/cli/bash/commands/basectl/tests/runtime-dispatch.bats index 489ac38e..9c526962 100644 --- a/cli/bash/commands/basectl/tests/runtime-dispatch.bats +++ b/cli/bash/commands/basectl/tests/runtime-dispatch.bats @@ -46,6 +46,60 @@ load ./basectl_helpers.bash } +@test "basectl removes the complete temp tree by default" { + run env \ + BASE_HOME="$BASE_REPO_ROOT" \ + BASE_TEST_TMPDIR="$TEST_TMPDIR" \ + bash -c ' + source "$BASE_HOME/cli/bash/commands/basectl/basectl.sh" + BASE_CACHE_DIR="$BASE_TEST_TMPDIR/cache" basectl_initialize_run_bundle setup base-demo || exit $? + mkdir -p "$BASE_CLI_RUN_ROOT/tmp/base_setup" + printf "temporary\n" >"$BASE_CLI_RUN_ROOT/tmp/base_setup/file.txt" + run_root="$BASE_CLI_RUN_ROOT" + basectl_finalize_run_bundle 0 || exit $? + printf "run_root=%s\n" "$run_root" + [[ ! -e "$run_root/tmp" ]] + ' + + [ "$status" -eq 0 ] +} + + +@test "basectl preserves the complete temp tree when explicitly requested" { + run env \ + BASE_HOME="$BASE_REPO_ROOT" \ + BASE_TEST_TMPDIR="$TEST_TMPDIR" \ + BASE_CLI_KEEP_TEMP=true \ + bash -c ' + source "$BASE_HOME/cli/bash/commands/basectl/basectl.sh" + BASE_CACHE_DIR="$BASE_TEST_TMPDIR/cache" basectl_initialize_run_bundle setup base-demo || exit $? + mkdir -p "$BASE_CLI_RUN_ROOT/tmp/base_setup" + printf "temporary\n" >"$BASE_CLI_RUN_ROOT/tmp/base_setup/file.txt" + run_root="$BASE_CLI_RUN_ROOT" + basectl_finalize_run_bundle 0 || exit $? + [[ -f "$run_root/tmp/base_setup/file.txt" ]] + ' + + [ "$status" -eq 0 ] +} + + +@test "basectl exposes keep-temp as a wrapper option" { + run env \ + BASE_HOME="$BASE_REPO_ROOT" \ + bash -c ' + source "$BASE_HOME/cli/bash/commands/basectl/basectl.sh" + log_debug() { :; } + basectl_get_base_home() { return 0; } + basectl_do_version() { printf "keep=%s\n" "${BASE_CLI_KEEP_TEMP:-}"; } + basectl_main --keep-temp version + ' + + [ "$status" -eq 0 ] + [ "$output" = "keep=true" ] +} + + @test "basectl prints help when no command is given in a non-interactive shell" { run_basectl diff --git a/docs/base-cli.md b/docs/base-cli.md index 937485cf..c7a884d3 100644 --- a/docs/base-cli.md +++ b/docs/base-cli.md @@ -191,7 +191,7 @@ Directory lifecycle: | Directory | Created | Removed | |---|---|---| -| `run.json`, `logs/`, `tmp/` | before command execution | run-bundle cleanup | +| `run.json`, `logs/`, `tmp/` | before command execution | run-bundle cleanup; `tmp/` is removed unless `--keep-temp` is requested | | `cache/components/` | when a component needs it | explicit CLI cleanup | Commands running with `ctx.dry_run` skip default `logs/`, `cache/`, and @@ -352,8 +352,9 @@ arguments. These are direct Python package options. Public `basectl` launchers expose `-v` for command-level debug logs and command-specific flags from `basectl --help`; they do not expose `--debug`, `--quiet`, -`--log-file`, `--config`, `--environment`, or `--keep-temp` as public -`basectl` options. +`--log-file`, `--config`, or `--environment` as public `basectl` options. +The wrapper-level `basectl --keep-temp ` option explicitly preserves +the complete temporary tree for that run. ## Interrupt And Cleanup @@ -362,7 +363,7 @@ register them on import. Cleanup should: 1. call user cleanup hooks 2. flush and close log handlers -3. remove `ctx.temp_dir` unless `keep_temp` is true +3. remove `ctx.temp_dir` and empty temporary parents unless `keep_temp` is true CLI authors can register hooks: diff --git a/docs/cache-ownership-and-layout.md b/docs/cache-ownership-and-layout.md index b86db3e5..6726201e 100644 --- a/docs/cache-ownership-and-layout.md +++ b/docs/cache-ownership-and-layout.md @@ -140,9 +140,11 @@ project run through the history index and `run.json` metadata. ### `tmp/` -Temporary files scoped to one run and component. Successful runs remove these -directories automatically unless retention is requested. Failed or interrupted -runs retain them for diagnosis until cleanup removes the completed bundle. +Temporary files scoped to one run and component. Base removes the complete +`tmp/` tree, including empty component parents, after every run by default. +Pass the explicit `--keep-temp` wrapper option to preserve the complete tree +for diagnosis. Run metadata, history, and `primary.log` are retained +independently of this choice. ## Timestamps and permissions diff --git a/docs/command-reference.md b/docs/command-reference.md index df75ea36..54312ced 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -11,8 +11,9 @@ syntax. `basectl` exposes `-v` as the public command-level debug switch. Direct `base_cli` package standard options such as `--debug`, `--quiet`, `--log-file`, -`--config`, `--environment`, and `--keep-temp` are private to Python package -execution and are rejected by `basectl`. +`--config` and `--environment` are private to Python package execution and are +rejected by `basectl`. Use `basectl --keep-temp ` when temporary run +files must be preserved for diagnosis; they are removed by default. ## Stability Tiers diff --git a/lib/python/base_cli/README.md b/lib/python/base_cli/README.md index f242edc9..fc347ec2 100644 --- a/lib/python/base_cli/README.md +++ b/lib/python/base_cli/README.md @@ -75,8 +75,9 @@ equals-form values such as `--name=Ada` before Click parses arguments. These are direct package options. Public `basectl` launchers expose `-v` for command-level debug logs and command-specific flags from `basectl --help`; they do not expose `--debug`, `--quiet`, -`--log-file`, `--config`, `--environment`, or `--keep-temp` as public -`basectl` options. +`--log-file`, `--config`, or `--environment` as public `basectl` options. +The wrapper-level `basectl --keep-temp ` option preserves the +complete temporary tree for that run. ## Command Registration diff --git a/lib/python/base_cli/context.py b/lib/python/base_cli/context.py index 314d8f45..0b564588 100644 --- a/lib/python/base_cli/context.py +++ b/lib/python/base_cli/context.py @@ -67,6 +67,11 @@ def cleanup(self) -> None: if not self.keep_temp and self.temp_dir.exists(): try: shutil.rmtree(self.temp_dir) + for parent in (self.temp_dir.parent, self.temp_dir.parent.parent): + try: + parent.rmdir() + except OSError: + break except OSError as exc: self.log.warning("Temp directory cleanup failed for '%s': %s", self.temp_dir, exc) for handler in list(self.log.handlers): diff --git a/lib/python/base_cli/tests/test_base_cli.py b/lib/python/base_cli/tests/test_base_cli.py index dede6c83..65986dab 100644 --- a/lib/python/base_cli/tests/test_base_cli.py +++ b/lib/python/base_cli/tests/test_base_cli.py @@ -598,6 +598,8 @@ def main(ctx: base_cli.Context, name: str) -> None: self.assertEqual(result.exit_code, 0, result.output) self.assertEqual(seen["name"], "Ada") self.assertFalse(seen["temp_dir"].exists()) + self.assertFalse(seen["temp_dir"].parent.exists()) + self.assertFalse(seen["temp_dir"].parent.parent.exists()) self.assertTrue(seen["cache_dir"].is_dir()) log_dir = home / ".cache" / "base" / "base" / "runs" / next( path.name for path in (home / ".cache" / "base" / "base" / "runs").iterdir() @@ -945,6 +947,7 @@ def main(ctx: base_cli.Context, token: str) -> None: self.assertEqual(seen["token"], "super-secret") self.assertTrue(seen["debug"]) self.assertTrue(seen["temp_dir"].exists()) + self.assertTrue(seen["temp_dir"].parent.exists()) self.assertEqual(seen["log_file"], log_file) self.assertTrue( seen["temp_dir"].is_relative_to(home / ".cache" / "base" / "base" / "runs")