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
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ From a local checkout, use the shorter equivalents:
./run.sh matrix
./run.sh man disk
./run.sh clean
./run.sh fetch --plain
./run.sh games list --json
```

Cleanup scripts always preview first and require explicit confirmation before
Expand All @@ -69,10 +71,15 @@ bin/ # Ready-to-run command-line tools
dev/ # Helpers for development, repositories, and local workflows
lib/ # Shared code used by more than one script
sys/ # OS maintenance, cleanup, and fixes
packs/ # Typed manuals for optional external executables; no wrapper logic
```

Scripts are grouped by purpose rather than programming language. Platform-
specific scripts can be placed below `sys/` when needed.
specific scripts can be placed below `sys/` when needed. A `packs/NAME.man`
manual may declare `"executable": "NAME"` to expose one installed external
tool through the same launcher and dashboard without duplicating it in Bash or
Python. Missing executables fail with an install hint; packs never download
software implicitly.

## Contributing

Expand Down
98 changes: 85 additions & 13 deletions dev/gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,10 @@
OUTPUT_LINE_LIMIT = 8 * 1024
HISTORY_LIMIT = 200
MAN_SUFFIX = ".man"
SEARCH_DIRS = ("bin", "dev", "sys")
SEARCH_DIRS = ("bin", "dev", "sys", "packs")
FLAG_PATTERN = re.compile(r"^--?[A-Za-z][A-Za-z0-9-]*$")
NAME_PATTERN = re.compile(r"^[a-z][a-z0-9-]*$")
COMMAND_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$")
PLATFORMS = {"any", "linux", "macos", "windows"}
PLATFORM_LABELS = {
"any": "Any",
Expand All @@ -89,6 +90,7 @@
"directWhen",
"findingExitCodes",
"launchable",
"executable",
"parameters",
"options",
}
Expand Down Expand Up @@ -166,6 +168,8 @@ class ScriptSpec:
apply_when: tuple[str, ...] = ()
direct_when: tuple[str, ...] = ()
launchable: bool = True
external: bool = False
installed: bool = True

@property
def supported(self) -> bool:
Expand Down Expand Up @@ -480,9 +484,6 @@ def parse_help(help_path: Path, root: Path) -> ScriptSpec:
)
if apply_when and apply_flag is None:
raise GuiError(f"{help_path}: applyWhen requires applyFlag")
if direct_when and apply_flag is None:
raise GuiError(f"{help_path}: directWhen requires applyFlag")

launchable = metadata.get("launchable", True)
if not isinstance(launchable, bool):
raise GuiError(f"{help_path}: launchable must be true or false")
Expand All @@ -498,11 +499,17 @@ def parse_help(help_path: Path, root: Path) -> ScriptSpec:
f"{help_path}: findingExitCodes must contain unique integers from 1 to 255"
)

script_path = Path(str(help_path)[: -len(MAN_SUFFIX)]).resolve()
if not script_path.is_file():
executable = metadata.get("executable")
if executable is not None and (
not isinstance(executable, str) or not COMMAND_PATTERN.fullmatch(executable)
):
raise GuiError(f"{help_path}: executable must be one command name")
adjacent_path = Path(str(help_path)[: -len(MAN_SUFFIX)]).resolve()
script_path = adjacent_path if executable is None else Path(executable)
if executable is None and not script_path.is_file():
raise GuiError(f"{help_path}: adjacent script does not exist")
try:
script_id = script_path.relative_to(root).as_posix()
script_id = adjacent_path.relative_to(root).as_posix()
except ValueError as error:
raise GuiError(f"{help_path}: script escapes the repository") from error

Expand Down Expand Up @@ -534,6 +541,8 @@ def parse_help(help_path: Path, root: Path) -> ScriptSpec:
apply_when=apply_when,
direct_when=direct_when,
launchable=launchable,
external=executable is not None,
installed=executable is None or shutil.which(executable) is not None,
)


Expand Down Expand Up @@ -657,6 +666,8 @@ def _resolve_program(program: str) -> str:


def command_for(script: ScriptSpec, flags: Iterable[str]) -> list[str]:
if script.external:
return [_resolve_program(str(script.path)), *flags]
if script.path.suffix.lower() == ".ps1":
powershell = shutil.which("pwsh") or shutil.which("powershell")
if powershell is None:
Expand Down Expand Up @@ -699,7 +710,8 @@ def command_for(script: ScriptSpec, flags: Iterable[str]) -> list[str]:


def display_command(command: Iterable[str]) -> str:
return shlex.join(command)
values = list(command)
return subprocess.list2cmdline(values) if os.name == "nt" else shlex.join(values)


def markdown_code_block(text: str) -> str:
Expand Down Expand Up @@ -1128,7 +1140,13 @@ def update_step(self) -> None:
self.query_one("#wizard-back", Button).disabled = self.step == 0
arguments = self.selected_arguments() if self.step == 2 else ()
self.query_one("#wizard-next", Button).label = (
("Preview" if self.script.needs_apply(arguments or ()) else "Run")
(
"Preview"
if self.script.needs_apply(arguments or ())
else "Show command"
if self.script.needs_direct_terminal(arguments or ())
else "Run"
)
if self.step == 2
else "Next"
)
Expand Down Expand Up @@ -1171,6 +1189,9 @@ def advance(self) -> None:
"This runs a read-only preview first, then shows the apply "
"command for a direct interactive terminal."
if needs_apply and self.script.needs_direct_terminal(arguments)
else "This interactive tool needs the terminal. Confirm to show the "
"exact command to run."
if self.script.needs_direct_terminal(arguments)
else "This runs a read-only preview first. Apply repeats "
"discovery with the same settings, so targets may change "
"between runs. A successful preview must be followed by `CLEAN`."
Expand Down Expand Up @@ -1732,20 +1753,22 @@ def refresh_catalog(self, query: str = "") -> None:
]
visible.sort(
key=lambda script: (
not (script.supported and script.launchable),
not (script.supported and script.launchable and script.installed),
not script.supported,
script.script_id,
)
)
table = self.query_one("#scripts", DataTable)
table.clear()
for script in visible:
ready = script.supported and script.launchable
ready = script.supported and script.launchable and script.installed
state = Text(
"● Ready"
if ready
else "◆ Guide"
if not script.launchable
else "○ Install"
if script.supported and not script.installed
else f"○ {PLATFORM_LABELS[script.platform]}",
style=(
"green"
Expand Down Expand Up @@ -1789,6 +1812,8 @@ def show_script(self, script: ScriptSpec) -> None:
badge = (
"Documentation only"
if not script.launchable
else f"Install `{script.path}` to run"
if script.supported and not script.installed
else "Available on this computer"
if script.supported
else f"Available on {PLATFORM_LABELS[script.platform]}"
Expand All @@ -1805,14 +1830,16 @@ def update_controls(self) -> None:
self.selected is not None
and self.selected.supported
and self.selected.launchable
and self.selected.installed
and self.started_at is None
)
self.query_one("#run", Button).disabled = not available
self.query_one("#cancel", Button).disabled = (
self.process is None or not cancel_allowed(self.active_phase)
)
supported = sum(
script.supported and script.launchable for script in self.scripts
script.supported and script.launchable and script.installed
for script in self.scripts
)
state = "running" if self.started_at is not None else "ready"
platform = PLATFORM_LABELS.get(host_platform(), host_platform())
Expand Down Expand Up @@ -1919,6 +1946,7 @@ def action_run(self) -> None:
self.selected is None
or not self.selected.supported
or not self.selected.launchable
or not self.selected.installed
or self.started_at is not None
):
return
Expand Down Expand Up @@ -2113,6 +2141,21 @@ async def run_once(
async def run_selection(self, selection: RunSelection) -> None:
try:
needs_apply = selection.script.needs_apply(selection.flags)
if (
selection.script.needs_direct_terminal(selection.flags)
and not needs_apply
):
command = display_command(
command_for(selection.script, selection.flags)
)
self.query_one("#details-tabs", TabbedContent).active = "output-pane"
self.query_one("#activity-heading", Label).update(
"Direct interactive terminal required"
)
log = self.query_one("#run-log", RichLog)
log.clear()
log.write(f"Run directly:\n$ {command}")
return
result = await self.run_once(
selection,
"Preview" if needs_apply else "Run",
Expand Down Expand Up @@ -2215,7 +2258,7 @@ def layout_snapshot(app: ScriptsApp) -> dict[str, tuple[int, int, int, int]]:
if legacy:
names = ", ".join(path.as_posix() for path in legacy)
raise GuiError(f"legacy .help manuals remain: {names}")
documented = {script.path for script in scripts}
documented = {script.path for script in scripts if not script.external}
commands = {path.resolve() for path in root.iterdir() if _is_command(path)}
commands |= {
path.resolve()
Expand All @@ -2242,6 +2285,17 @@ def layout_snapshot(app: ScriptsApp) -> dict[str, tuple[int, int, int, int]]:
)
if dashboard is None or dashboard.launchable:
raise GuiError("dashboard manual must be present and non-launchable")
external = next((script for script in scripts if script.external), None)
if external is not None:
with patch("shutil.which", return_value="/installed/tool"):
assert command_for(external, ("--plain",)) == [
"/installed/tool",
"--plain",
]
with patch.object(os, "name", "nt"):
assert display_command((r"C:\Program Files\fetch.exe", "--plain")) == (
'"C:\\Program Files\\fetch.exe" --plain'
)

with tempfile.TemporaryDirectory(prefix="scripts-gui-contract-") as directory:
contract_root = Path(directory).resolve()
Expand Down Expand Up @@ -2308,6 +2362,18 @@ def layout_snapshot(app: ScriptsApp) -> dict[str, tuple[int, int, int, int]]:
probe.parameters[1].when,
{"action": "show"},
)
metadata["executable"] = "../escape"
help_path.write_text(
f"---\n{json.dumps(metadata)}\n---\n{probe_markdown}",
encoding="utf-8",
)
try:
parse_help(help_path, contract_root)
except GuiError as error:
assert "executable must be one command name" in str(error)
else:
raise AssertionError("unsafe external executable was accepted")
metadata.pop("executable")
metadata["privlege"] = metadata.pop("privilege")
help_path.write_text(
f"---\n{json.dumps(metadata)}\n---\n{probe_markdown}",
Expand Down Expand Up @@ -2366,6 +2432,12 @@ def layout_snapshot(app: ScriptsApp) -> dict[str, tuple[int, int, int, int]]:
assert table.get_row(other_os.script_id)[0].plain == (
f"○ {PLATFORM_LABELS[other_os.platform]}"
)
missing_external = next(
(script for script in scripts if script.external and not script.installed),
None,
)
if missing_external is not None:
assert table.get_row(missing_external.script_id)[0].plain == "○ Install"
compact.show_script(runnable)
await pilot.pause()
assert help_scroll.max_scroll_y > 0
Expand Down
11 changes: 6 additions & 5 deletions dev/gui.py.man
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,10 @@

# Responsive scripts dashboard

Discovers adjacent `.man` manuals under `bin`, `dev`, and `sys`, then renders
their Markdown, typed parameters, warnings, progress, files, and command output
in one keyboard- and mouse-friendly interface.
Discovers adjacent `.man` manuals under `bin`, `dev`, `sys`, and `packs`, then
renders their Markdown, typed parameters, warnings, progress, files, and
command output in one keyboard- and mouse-friendly interface. External packs
resolve one installed executable and contain no shell or Python wrapper.

> **Apply safety:** Destructive commands always preview first and require
> `CLEAN`. Cancel and Quit are disabled during apply because interrupting
Expand All @@ -46,8 +47,8 @@ non-interactive use; a normal terminal credential cache usually does not carry
into the isolated child process. Run the displayed command directly when a
password is required. Launch the Windows dashboard from an Administrator
terminal for privileged actions. Commands that need their own interactive
terminal are previewed in the dashboard, which then shows the direct apply
command.
terminal show the exact direct command instead of corrupting either full-screen
interface.

Without `--root`, the dashboard uses a local checkout when one can be found;
otherwise it downloads a temporary copy of the repository and removes it when
Expand Down
Loading
Loading