Skip to content

Commit c398901

Browse files
authored
Fix base-cli review findings
Fixes #7.
1 parent 047c587 commit c398901

23 files changed

Lines changed: 460 additions & 79 deletions

‎README.md‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -306,9 +306,15 @@ def helper() -> None:
306306
`--quiet` suppresses INFO output on the user-facing stream but still shows
307307
warnings and errors. `--debug` and `--quiet` cannot be used together. Persistent
308308
log files still receive DEBUG-level detail, including INFO messages suppressed
309-
from stderr. When `basectl --color` is used on a terminal, the user-facing
310-
Python logs use the same level colors as Bash logs; persistent log files remain
311-
plain text. `NO_COLOR` disables colors.
309+
from stderr. User-facing logs use colors automatically on interactive terminals;
310+
persistent log files remain plain text. Set `NO_COLOR=1` or
311+
`BASE_CLI_COLOR=0` to disable colors. The Base wrapper's `--color` option
312+
remains compatible with this behavior.
313+
314+
Click also provides shell completion. For an app named `hello`, request a
315+
completion script with `_HELLO_COMPLETE=bash_source hello`, replacing `bash`
316+
with `zsh` or `fish` as needed. `base_cli` leaves installation to the caller so
317+
shell startup files remain under user control.
312318

313319
Advanced tests and CI wrappers can call `base_cli.configure_logger(...,
314320
stream=..., formatter=...)` to capture user-facing logs or apply a custom
@@ -406,7 +412,7 @@ for those structured values.
406412
The user config file is machine-local by default. Base owns the semantics of
407413
`~/.base.d/config.yaml`, while users own backup and sync choices such as iCloud,
408414
chezmoi, dotfiles repositories, Time Machine, or manual copy. See
409-
`docs/local-config.md` for the product-level boundary.
415+
[`docs/local-config.md`](docs/local-config.md) for the product-level boundary.
410416

411417
## Project Discovery
412418

@@ -426,7 +432,7 @@ actionable message.
426432

427433
Runtime state is rooted at `~/Library/Caches/base` on macOS and `~/.cache/base`
428434
elsewhere. `BASE_CACHE_DIR` overrides the root. See
429-
[`docs/cache-ownership-and-layout.md`](../../../docs/cache-ownership-and-layout.md)
435+
[`docs/cache-ownership-and-layout.md`](docs/cache-ownership-and-layout.md)
430436
for the owner-aware layout. Base control-plane commands use `base/`; a
431437
Base-compliant project's own commands use `projects/<project>/<checkout-id>/`.
432438
Each invocation is a run bundle containing private (`0600`) `run.json`,

‎docs/cache-ownership-and-layout.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Cache ownership and layout
2+
3+
Runtime state is rooted at `~/Library/Caches/base` on macOS and `~/.cache/base`
4+
elsewhere. Set `BASE_CACHE_DIR` to override the root.
5+
6+
The `base` owner stores Base control-plane runs directly below `base/`. A
7+
project-owned runtime uses `projects/<project>/<checkout-id>/` so separate
8+
checkouts do not share mutable run state accidentally.
9+
10+
Each invocation has a private run bundle containing:
11+
12+
- `run.json` for lifecycle metadata;
13+
- `logs/` for diagnostic logs; and
14+
- `tmp/` for temporary command data.
15+
16+
Persistent component caches live under the owner's `cache/components/` path.
17+
Runtime directories are owner-only (`0700`), and runtime files are owner-only
18+
(`0600`).

‎docs/local-config.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Local configuration
2+
3+
`base-cli` reads machine-local configuration from `~/.base.d/config.yaml`.
4+
Project configuration is read from `<project>/.base/config.yaml`, and an
5+
explicit `--config` file can provide the final project-specific override.
6+
7+
The package owns the configuration schema and merge semantics. Users own the
8+
operational choice of whether to back up or synchronize the machine-local file,
9+
using tools such as iCloud, chezmoi, a dotfiles repository, Time Machine, or a
10+
manual copy. The file can contain paths and other machine-specific values and
11+
should not be synchronized blindly across incompatible machines.

‎lib/python/base_cli/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,7 @@ for those structured values.
391391
The user config file is machine-local by default. Base owns the semantics of
392392
`~/.base.d/config.yaml`, while users own backup and sync choices such as iCloud,
393393
chezmoi, dotfiles repositories, Time Machine, or manual copy. See
394-
`docs/local-config.md` for the product-level boundary.
394+
[`docs/local-config.md`](../../../docs/local-config.md) for the product-level boundary.
395395

396396
## Project Discovery
397397

‎lib/python/base_cli/__init__.py‎

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,16 @@
77
def _resolve_version() -> str:
88
"""Return the checkout version or the installed distribution version."""
99

10-
for parent in Path(__file__).resolve().parents:
11-
version_file = parent / "VERSION"
10+
package_dir = Path(__file__).resolve().parent
11+
python_dir = package_dir.parent
12+
lib_dir = python_dir.parent
13+
checkout_root = lib_dir.parent
14+
if (
15+
python_dir.name == "python"
16+
and lib_dir.name == "lib"
17+
and (checkout_root / "pyproject.toml").is_file()
18+
):
19+
version_file = checkout_root / "VERSION"
1220
if version_file.is_file():
1321
value = version_file.read_text(encoding="utf-8").splitlines()[0].strip()
1422
if value:
@@ -25,7 +33,17 @@ def _resolve_version() -> str:
2533
from . import command_filters, command_protocol, history, testing
2634
from .app import App, argument, command, delegated_display_command, option, run_app
2735
from .command_filters import command_matches, normalize_command_filter, normalize_command_filters
28-
from .command_protocol import CommandProtocolError, dumps_record, dumps_records, loads_records
36+
from .command_protocol import (
37+
BOOLEAN,
38+
NULLABLE_STRING,
39+
STRING,
40+
CommandProtocolError,
41+
FieldSpec,
42+
dumps_record,
43+
dumps_records,
44+
loads_records,
45+
register_record_schema,
46+
)
2947
from .config import UserConfig, UserGithubConfig, UserIdeConfig, UserIdePreference, UserWorkspaceConfig
3048
from .context import Context, get_current_context
3149
from .exit_codes import ExitCode
@@ -44,9 +62,13 @@ def _resolve_version() -> str:
4462
__all__ = [
4563
"App",
4664
"__version__",
65+
"BOOLEAN",
4766
"CommandProtocolError",
4867
"Context",
4968
"ExitCode",
69+
"FieldSpec",
70+
"NULLABLE_STRING",
71+
"STRING",
5072
"UserConfig",
5173
"UserGithubConfig",
5274
"UserIdeConfig",
@@ -81,6 +103,7 @@ def _resolve_version() -> str:
81103
"option",
82104
"render_document",
83105
"render_records",
106+
"register_record_schema",
84107
"resolve_output_format",
85108
"run_app",
86109
]
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""Optional dependency loaders shared by base_cli modules."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
8+
def require_yaml(error_message: str) -> Any:
9+
"""Import PyYAML or raise the caller's feature-specific error."""
10+
11+
try:
12+
import yaml
13+
except ImportError as exc:
14+
raise RuntimeError(error_message) from exc
15+
return yaml
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""Helpers for writing private runtime files."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import os
7+
from collections.abc import Mapping
8+
from pathlib import Path
9+
from typing import Any
10+
11+
12+
PRIVATE_FILE_MODE = 0o600
13+
14+
15+
def restrict_file(path: Path) -> None:
16+
"""Ensure an existing runtime file is readable and writable only by its owner."""
17+
18+
path.chmod(PRIVATE_FILE_MODE)
19+
20+
21+
def write_private_json(path: Path, value: Mapping[str, Any]) -> None:
22+
"""Write a JSON mapping with owner-only permissions from the moment it is created."""
23+
24+
path.parent.mkdir(parents=True, exist_ok=True)
25+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, PRIVATE_FILE_MODE)
26+
try:
27+
fchmod = getattr(os, "fchmod", None)
28+
if fchmod is not None:
29+
fchmod(fd, PRIVATE_FILE_MODE)
30+
stream = os.fdopen(fd, "w", encoding="utf-8")
31+
fd = -1
32+
with stream:
33+
json.dump(value, stream, sort_keys=True)
34+
stream.write("\n")
35+
finally:
36+
if fd != -1:
37+
os.close(fd)
38+
restrict_file(path)

‎lib/python/base_cli/_runtime.py‎

Lines changed: 54 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
from __future__ import annotations
22

3+
import json
34
import logging
45
from dataclasses import dataclass
56
from pathlib import Path
67

8+
from ._private_files import write_private_json
79
from .paths import runtime_owner_root, runtime_run_directory_name
810

911

@@ -17,6 +19,9 @@ class RuntimeLayout:
1719
temp_dir: Path
1820

1921

22+
_LOG_INDEX_NAME = ".base-cli-log-index.json"
23+
24+
2025
# pylint: disable=too-many-arguments
2126
def runtime_layout(
2227
cache_root: Path,
@@ -45,8 +50,17 @@ def runtime_layout(
4550

4651

4752
def create_runtime_directory(path: Path, cache_root: Path) -> None:
53+
missing: list[Path] = []
54+
candidate = path
55+
while not candidate.exists():
56+
missing.append(candidate)
57+
candidate = candidate.parent
58+
restrict_permissions = _is_within(path, cache_root)
4859
try:
4960
path.mkdir(parents=True, exist_ok=True)
61+
if restrict_permissions:
62+
for directory in [path, *missing]:
63+
directory.chmod(0o700)
5064
except OSError as exc:
5165
raise RuntimeError(_runtime_directory_error(path, cache_root, exc)) from exc
5266

@@ -57,21 +71,48 @@ def prune_log_files(
5771
max_log_files: int,
5872
logger: logging.Logger,
5973
) -> None:
60-
candidates: list[tuple[str, Path]] = []
61-
for path in log_dir.rglob("*.log"):
62-
if _same_path(path, current_log_file):
63-
continue
64-
candidates.append((path.name, path))
74+
index_path = log_dir / _LOG_INDEX_NAME
75+
tracked = _read_log_index(index_path)
76+
if tracked is None:
77+
tracked = {path.resolve() for path in log_dir.glob("*/logs/*.log")}
78+
tracked.add(current_log_file.resolve())
79+
candidates = [(path.name, path) for path in tracked if not _same_path(path, current_log_file)]
6580

6681
excess_count = len(candidates) + 1 - max_log_files
67-
if excess_count <= 0:
68-
return
69-
70-
for _, path in sorted(candidates)[:excess_count]:
71-
try:
72-
path.unlink()
73-
except OSError as exc:
74-
logger.warning("Could not prune log file '%s': %s", path, exc)
82+
if excess_count > 0:
83+
for _, path in sorted(candidates)[:excess_count]:
84+
try:
85+
path.unlink()
86+
tracked.discard(path)
87+
except OSError as exc:
88+
logger.warning("Could not prune log file '%s': %s", path, exc)
89+
90+
tracked = {path for path in tracked if path.exists() or _same_path(path, current_log_file)}
91+
try:
92+
write_private_json(index_path, {"version": 1, "logs": sorted(str(path) for path in tracked)})
93+
except (OSError, TypeError, ValueError) as exc:
94+
logger.debug("Could not update log retention index '%s': %s", index_path, exc)
95+
96+
97+
def _read_log_index(path: Path) -> set[Path] | None:
98+
try:
99+
payload = json.loads(path.read_text(encoding="utf-8"))
100+
except (OSError, json.JSONDecodeError):
101+
return None
102+
if not isinstance(payload, dict) or payload.get("version") != 1:
103+
return None
104+
logs = payload.get("logs")
105+
if not isinstance(logs, list) or not all(isinstance(value, str) for value in logs):
106+
return None
107+
return {Path(value) for value in logs}
108+
109+
110+
def _is_within(path: Path, root: Path) -> bool:
111+
try:
112+
path.resolve().relative_to(root.resolve())
113+
except ValueError:
114+
return False
115+
return True
75116

76117

77118
def _runtime_directory_error(path: Path, cache_root: Path, exc: OSError) -> str:

‎lib/python/base_cli/app.py‎

Lines changed: 32 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
from __future__ import annotations
22

33
import functools
4-
import json
54
import os
65
import sys
76
from contextvars import ContextVar
87
from pathlib import Path
98
from typing import Any, Callable
109

1110
from ._runtime import create_runtime_directory, prune_log_files, runtime_layout
11+
from ._private_files import write_private_json
1212
from .config import load_config, read_user_config
1313
from .context import Context, reset_current_context, set_current_context
1414
from .exit_codes import ExitCode
@@ -170,7 +170,10 @@ def wrapper(**kwargs: Any):
170170
if context.manifest_path is not None:
171171
context.log.debug("manifest_path=%s", context.manifest_path)
172172
result = func(context, **kwargs)
173-
exit_code = int(result or ExitCode.SUCCESS)
173+
try:
174+
exit_code = _normalize_command_result(result)
175+
except TypeError as exc:
176+
raise click.ClickException(str(exc)) from exc
174177
return result
175178
except Exception:
176179
exit_code = ExitCode.FAILURE
@@ -238,6 +241,7 @@ def _create_context(self, standard: dict[str, Any], sensitive_options: set[str],
238241
log_file = _default_log_file(layout, inherited_path)
239242
create_runtime_directory(log_file.parent, cache_root)
240243
if inherited_path is None and not dry_run and self.log_to_file:
244+
create_runtime_directory(layout.owner_root, cache_root)
241245
create_runtime_directory(layout.run_root, cache_root)
242246
try:
243247
run_metadata = {
@@ -252,33 +256,24 @@ def _create_context(self, standard: dict[str, Any], sensitive_options: set[str],
252256
"workspace_root": str(user_config.workspace.root) if user_config.workspace.root else None,
253257
}
254258
run_metadata_path = layout.run_root / "run.json"
255-
run_metadata_path.write_text(
256-
json.dumps(run_metadata, sort_keys=True) + "\n",
257-
encoding="utf-8",
258-
)
259-
run_metadata_path.chmod(0o600)
259+
write_private_json(run_metadata_path, run_metadata)
260260
except OSError:
261261
pass
262262
if runtime_owner == "project" and selected_project_root is not None and not dry_run and self.log_to_file:
263263
try:
264264
create_runtime_directory(layout.owner_root, cache_root)
265265
identity_path = layout.owner_root / "identity.json"
266266
if not identity_path.exists():
267-
identity_path.write_text(
268-
json.dumps(
269-
{
270-
"schema_version": 1,
271-
"project": selected_project_name,
272-
"project_root": str(selected_project_root),
273-
"manifest": str(manifest_path) if manifest_path is not None else None,
274-
"checkout_id": layout.owner_root.name,
275-
},
276-
sort_keys=True,
277-
)
278-
+ "\n",
279-
encoding="utf-8",
267+
write_private_json(
268+
identity_path,
269+
{
270+
"schema_version": 1,
271+
"project": selected_project_name,
272+
"project_root": str(selected_project_root),
273+
"manifest": str(manifest_path) if manifest_path is not None else None,
274+
"checkout_id": layout.owner_root.name,
275+
},
280276
)
281-
identity_path.chmod(0o600)
282277
except OSError:
283278
pass
284279
logger = configure_logger(self.name, log_file, debug, quiet=quiet)
@@ -341,7 +336,22 @@ def run_app(app: App, argv: list[str] | None = None) -> int:
341336
except click.ClickException as exc:
342337
exc.show()
343338
return int(exc.exit_code)
344-
return int(result or 0)
339+
try:
340+
return _normalize_command_result(result)
341+
except TypeError as exc:
342+
print(f"ERROR: {exc}", file=sys.stderr)
343+
return ExitCode.FAILURE
344+
345+
346+
def _normalize_command_result(result: Any) -> int:
347+
if result is None:
348+
return ExitCode.SUCCESS
349+
if isinstance(result, int):
350+
return result
351+
raise TypeError(
352+
"Commands must return None or an int exit code; "
353+
f"got {type(result).__name__}."
354+
)
345355

346356

347357
def _effective_invocation_argv(

0 commit comments

Comments
 (0)