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
105 changes: 98 additions & 7 deletions cli/bash/commands/basectl/basectl.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -526,20 +529,92 @@ basectl_cache_root() {
fi
}

basectl_runtime_slug() {
local value="${1,,}" slug="" char
local index

for ((index = 0; index < ${#value}; index++)); do
char="${value:index:1}"
case "$char" in
[a-z0-9._-]) slug+="$char" ;;
*)
[[ -n "$slug" && "${slug: -1}" != "-" ]] && slug+="-"
;;
esac
done
while [[ "$slug" == -* ]]; do slug="${slug#-}"; done
while [[ "$slug" == *- ]]; do slug="${slug%-}"; done
[[ -n "$slug" ]] || slug="run"
printf '%s\n' "${slug:0:40}"
}

basectl_run_bundle_project() {
local command="$1"
local argument option_value=0
shift

case "$command" in
setup|check|doctor|test|build|demo|run|activate|export-context|devcontainer|devenv-report|onboard|update)
;;
trust)
[[ "${1:-}" == status || "${1:-}" == allow || "${1:-}" == revoke ]] && shift
;;
*)
return 0
;;
esac

for argument in "$@"; do
if ((option_value)); then
option_value=0
continue
fi
case "$argument" in
--manifest|--format|--profile|--environment|--config|--log-file|--project|--workspace|--path|--target|--version|--command|--status|--older-than|--keep-last|--since|--until|--last|--lines)
option_value=1
;;
--*=*|--*|-*)
;;
*)
basectl_runtime_slug "$argument"
return 0
;;
esac
done
}

basectl_run_bundle_label() {
local command_slug project_slug
command_slug="$(basectl_runtime_slug "${1:-run}")"
shift || true
project_slug="$(basectl_run_bundle_project "$command_slug" "$@")"
if [[ -n "$project_slug" ]]; then
printf '%s__%s\n' "$command_slug" "$project_slug"
else
printf '%s\n' "$command_slug"
fi
}

basectl_initialize_run_bundle() {
local cache_root run_id run_root
local command="${1:-run}" cache_root run_id run_root label
shift || true

if [[ -n "${BASE_CLI_RUN_ROOT:-}" ]]; then
export BASE_CLI_RUNTIME_OWNER=base
export BASE_CLI_RUN_ID="${BASE_CLI_RUN_ID:-$(basename -- "$BASE_CLI_RUN_ROOT")}"
if [[ -z "${BASE_CLI_RUN_ID:-}" ]]; then
BASE_CLI_RUN_ID="$(basename -- "$BASE_CLI_RUN_ROOT")"
BASE_CLI_RUN_ID="${BASE_CLI_RUN_ID%%__*}"
export BASE_CLI_RUN_ID
fi
export BASE_CLI_PRIMARY_LOG="${BASE_CLI_PRIMARY_LOG:-$BASE_CLI_RUN_ROOT/logs/primary.log}"
export BASE_CLI_HISTORY_PARENT_RUN_ID="${BASE_CLI_HISTORY_PARENT_RUN_ID:-$BASE_CLI_RUN_ID}"
return 0
fi

cache_root="$(basectl_cache_root)"
run_id="$(date -u +%Y%m%dT%H%M%S 2>/dev/null || true)_${BASHPID:-$$}_${RANDOM}"
run_root="$cache_root/base/runs/$run_id"
label="$(basectl_run_bundle_label "$command" "$@")"
run_root="$cache_root/base/runs/${run_id}__${label}"
mkdir -p "$run_root/logs" "$run_root/tmp" || {
basectl_error "Unable to create Base run bundle '$run_root'. Check BASE_CACHE_DIR permissions."
return 1
Expand Down Expand Up @@ -571,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() {
Expand Down Expand Up @@ -610,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

Expand Down Expand Up @@ -680,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=("$@")
Expand All @@ -690,8 +778,11 @@ 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 || return 1
basectl_initialize_run_bundle "$command" "$@" || return 1
fi
export BASE_CLI_HISTORY_SCOPE=internal
BASE_CLI_HISTORY_STARTED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || true)"
Expand Down
1 change: 1 addition & 0 deletions cli/bash/commands/basectl/tests/help.bats
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ load ./basectl_helpers.bash
[[ "$output" == *"--debug-wrapper"* ]]
[[ "$output" == *"--verbose-wrapper"* ]]
[[ "$output" == *"--utc-wrapper"* ]]
[[ "$output" == *"--keep-temp"* ]]
[[ "$output" == *"--color"* ]]
}

Expand Down
74 changes: 74 additions & 0 deletions cli/bash/commands/basectl/tests/runtime-dispatch.bats
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,80 @@ load ./basectl_helpers.bash
}


@test "basectl labels run bundle directories without changing the run ID" {
run env \
BASE_HOME="$BASE_REPO_ROOT" \
BASE_TEST_TMPDIR="$TEST_TMPDIR" \
bash -c '
source "$BASE_HOME/cli/bash/commands/basectl/basectl.sh"
printf "label=%s\n" "$(basectl_run_bundle_label setup base-demo)"
BASE_CACHE_DIR="$BASE_TEST_TMPDIR/cache" basectl_initialize_run_bundle setup base-demo || exit $?
printf "run_id=%s\n" "$BASE_CLI_RUN_ID"
printf "run_root=%s\n" "$BASE_CLI_RUN_ROOT"
'

[ "$status" -eq 0 ]
[[ "$output" == *"label=setup__base-demo"* ]]
[[ "$output" == *"run_root="*"__setup__base-demo" ]]
run_id="$(printf '%s\n' "$output" | sed -n 's/^run_id=//p')"
[[ "$run_id" != *"__"* ]]
}


@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

Expand Down
33 changes: 21 additions & 12 deletions cli/python/base_logs/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
from base_cli.redaction import redact_text_value


RUN_ID_RE = re.compile(r"^(?P<stamp>\d{8}T\d{6})_[A-Za-z0-9]+$")
RUN_ID_RE = re.compile(r"^(?P<stamp>\d{8}T\d{6})_[A-Za-z0-9]+(?:__.*)?$")
LOG_SECRET_ASSIGNMENT_RE = re.compile(
r"(?P<key>\b[A-Za-z_][A-Za-z0-9_.-]*)=(?P<value>[^\s,;]+)",
re.IGNORECASE,
Expand Down Expand Up @@ -449,15 +449,16 @@ def discover_log_entries(cache_root: Path) -> list[LogEntry]:
for path in sorted(logs_root.glob("primary.log"), key=str):
if not path.is_file():
continue
history_status = history_status_for_log(history_statuses, run_id=run_root.name, path=path)
run_id = canonical_run_id(run_root)
history_status = history_status_for_log(history_statuses, run_id=run_id, path=path)
raw_command = raw_command_for_log(path)
entries.append(
LogEntry(
command=history_status.command
if history_status is not None and history_status.command is not None
else infer_display_command(raw_command, path),
raw_command=raw_command,
run_id=run_root.name,
run_id=run_id,
path=path,
timestamp=entry_timestamp(path),
status=history_status.status if history_status is not None else infer_status(path),
Expand All @@ -484,16 +485,24 @@ def infer_raw_command_from_log_path(path: Path) -> str:

def raw_command_for_log(path: Path) -> str:
"""Resolve the command identity without encoding it in the log filename."""
metadata_path = path.parent.parent / "run.json"
metadata = run_metadata(path.parent.parent)
value = optional_string(metadata.get("raw_command")) or optional_string(metadata.get("cli"))
if value:
return value
return infer_raw_command_from_log_path(path)


def run_metadata(run_root: Path) -> dict[str, Any]:
try:
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
payload = json.loads((run_root / "run.json").read_text(encoding="utf-8"))
except (OSError, TypeError, ValueError):
metadata = {}
if isinstance(metadata, dict):
value = optional_string(metadata.get("raw_command")) or optional_string(metadata.get("cli"))
if value:
return value
return infer_raw_command_from_log_path(path)
return {}
return payload if isinstance(payload, dict) else {}


def canonical_run_id(run_root: Path) -> str:
value = optional_string(run_metadata(run_root).get("run_id"))
return value or run_root.name.split("__", 1)[0]


def read_history_log_statuses(cache_root: Path) -> HistoryLogStatusIndex:
Expand Down Expand Up @@ -572,7 +581,7 @@ def first_lines(path: Path, limit: int) -> list[str]:


def entry_timestamp(path: Path) -> datetime:
run_id = path.parent.parent.name if path.name == "primary.log" else path.stem
run_id = canonical_run_id(path.parent.parent) if path.name == "primary.log" else path.stem.split("__", 1)[0]
match = RUN_ID_RE.match(run_id)
if match is not None:
try:
Expand Down
19 changes: 19 additions & 0 deletions cli/python/base_logs/tests/test_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,25 @@ def test_recent_logs_sorts_by_run_id_timestamp(self) -> None:
self.assertEqual([entry.path for entry in entries], [new_path, old_path])
self.assertEqual([entry.command for entry in entries], ["clean", "projects"])

def test_readable_bundle_name_does_not_change_canonical_run_id(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
cache_root = Path(tmpdir)
run_id = "20260601T010000_aaaaaaaa"
run_root = cache_root / "base" / "runs" / f"{run_id}__setup__demo"
log_path = run_root / "logs" / "primary.log"
log_path.parent.mkdir(parents=True)
log_path.write_text("INFO setup\n", encoding="utf-8")
(run_root / "run.json").write_text(
json.dumps({"run_id": run_id, "cli": "base_setup"}) + "\n",
encoding="utf-8",
)

entries = engine.recent_logs(cache_root)

self.assertEqual(len(entries), 1)
self.assertEqual(entries[0].run_id, run_id)
self.assertEqual(entries[0].timestamp.strftime("%Y%m%dT%H%M%S"), "20260601T010000")

def test_base_setup_command_is_inferred_from_action(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
cache_root = Path(tmpdir)
Expand Down
13 changes: 7 additions & 6 deletions docs/base-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,10 +169,10 @@ root.
<base-cache-root>/
base/
history/runs.jsonl
runs/<run-id>/{run.json,logs/,tmp/}
runs/<run-id>__<command>__<project>/{run.json,logs/,tmp/}
cache/components/<cli-name>/
projects/<project>/<checkout-id>/
runs/<run-id>/{run.json,logs/,tmp/}
runs/<run-id>__<command>__<project>/{run.json,logs/,tmp/}
cache/components/<cli-name>/
<run-id>/
```
Expand All @@ -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
Expand Down Expand Up @@ -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 <command> --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 <command>` option explicitly preserves
the complete temporary tree for that run.

## Interrupt And Cleanup

Expand All @@ -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:

Expand Down
Loading
Loading