Skip to content
Open
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
6 changes: 6 additions & 0 deletions src/specify_cli/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Allow ``python -m specify_cli`` to run the CLI."""

from specify_cli import main

if __name__ == "__main__":
main()
204 changes: 204 additions & 0 deletions src/specify_cli/presets/_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import hashlib
import json
import os
import shutil
import tempfile
from pathlib import Path
Expand Down Expand Up @@ -150,6 +151,96 @@ def _materialize_constitution_template(
return result


# Generated files for script composition (#4551). Both carry no
# stack-specific data — only the script's own name — so they never need
# rewriting when priorities/enablement change; the dispatcher resolves the
# live chain on every invocation via `specify preset script-chain`. They are
# generated (not shipped under scripts/bash) so projects initialized before
# this feature get the runner too, and shared-infra inventories are unchanged.
_SCRIPT_DISPATCHER_MARKER = "# speckit-generated: script continuation dispatcher"
_SCRIPT_RUNNER_MARKER = "# speckit-generated: script continuation runner"
_SCRIPT_RUNNER_NAME = "continuation-runner.sh"
# Names the generated runtime relies on: a dispatcher for ``common`` would
# overwrite the library every dispatcher sources, and one for
# ``continuation-runner`` would overwrite the runner itself.
_RESERVED_SCRIPT_NAMES = frozenset({"common", "continuation-runner"})

_SCRIPT_CONTINUATION_DISPATCHER_TEMPLATE = """#!/usr/bin/env bash
# speckit-generated: script continuation dispatcher
# Generated by specify for the "{script_name}" script. Do not edit directly;
# customize via presets/overrides instead (`specify preset add`).
set -e
set -o pipefail

SCRIPT_DIR="$(CDPATH="" cd -- "$(dirname -- "${{BASH_SOURCE[0]}}")" && pwd)"
source "$SCRIPT_DIR/common.sh"

REPO_ROOT=$(get_repo_root)
if command -v specify >/dev/null 2>&1; then
SPECIFY_CMD=(specify)
elif command -v python3 >/dev/null 2>&1; then
SPECIFY_CMD=(python3 -m specify_cli)
else
echo "ERROR: 'specify' is required to resolve the '{script_name}' script chain" >&2
exit 1
fi
# Strip carriage returns: on Windows the CLI emits CRLF, and $(...) only
# strips trailing newlines, so a stray CR would corrupt the exec path.
CHAIN=$( (cd "$REPO_ROOT" && "${{SPECIFY_CMD[@]}}" preset script-chain "{script_name}") | tr -d '\\r' ) || {{
echo "ERROR: could not resolve the script chain for '{script_name}'" >&2
exit 1
}}

TOP_LAYER=$(printf '%s\\n' "$CHAIN" | head -n 1)
REMAINING=$(printf '%s\\n' "$CHAIN" | tail -n +2)

if [[ -n "$REMAINING" ]]; then
export SPECKIT_SCRIPT_CONTINUATION="$REMAINING"
export CORE_SCRIPT="$SCRIPT_DIR/{runner_name}"
fi

# Run via bash so layers need no execute bit (preset copies keep source modes).
exec bash "$TOP_LAYER" "$@"
"""

_SCRIPT_CONTINUATION_RUNNER = """#!/usr/bin/env bash
# speckit-generated: script continuation runner
# Advances the script continuation one hop: consumes the head of
# SPECKIT_SCRIPT_CONTINUATION (newline-delimited remaining layers, highest
# priority first) and runs it, re-exporting the remainder so a further
# "wrap" layer's own $CORE_SCRIPT call continues correctly.
# Portable to bash 3.2: no mapfile or associative arrays.
set -e

if [[ -z "${SPECKIT_SCRIPT_CONTINUATION:-}" ]]; then
echo "ERROR: continuation runner invoked with no remaining script layers" >&2
exit 1
fi

__speckit_remaining=()
while IFS= read -r __speckit_line; do
__speckit_line="${__speckit_line%$'\\r'}"
[[ -n "$__speckit_line" ]] && __speckit_remaining+=("$__speckit_line")
done <<< "$SPECKIT_SCRIPT_CONTINUATION"

if [[ ${#__speckit_remaining[@]} -eq 0 ]]; then
echo "ERROR: continuation runner found no remaining script layers" >&2
exit 1
fi

__speckit_next="${__speckit_remaining[0]}"

if [[ ${#__speckit_remaining[@]} -gt 1 ]]; then
SPECKIT_SCRIPT_CONTINUATION=$(printf '%s\\n' "${__speckit_remaining[@]:1}")
export SPECKIT_SCRIPT_CONTINUATION
else
unset SPECKIT_SCRIPT_CONTINUATION
fi

exec bash "$__speckit_next" "$@"
"""


class PresetManager(_PresetCommandMethods, _PresetSkillMethods):
"""Manages preset lifecycle: installation, removal, updates."""

Expand Down Expand Up @@ -481,6 +572,29 @@ def install_from_directory(
stacklevel=2,
)

# Scripts don't need the same repeated reconciliation as commands:
# once a script name's canonical file is a continuation dispatcher,
# it re-resolves the live stack on every invocation (#4551), so
# later priority/enablement changes take effect without touching
# this file again. Only install (here) and remove() ever need to
# write it.
script_names = [
t["name"]
for t in manifest.templates
if t.get("type") == "script"
]
for script_name in script_names:
try:
self._reconcile_script_chain(script_name)
except Exception as exc:
import warnings
warnings.warn(
f"Post-install script reconciliation failed for "
f"{manifest.id} script '{script_name}': {exc}. "
f"Run 'specify preset script-chain {script_name}' to diagnose.",
stacklevel=2,
)

# TODO: constitution-sync is a named preset with core-owned side effects.
# Give synchronization an explicit owner without changing its opt-in
# behavior or overwriting authored constitutions.
Expand All @@ -492,6 +606,78 @@ def install_from_directory(

return manifest

def _reconcile_script_chain(self, script_name: str) -> None:
"""Materialize the project's canonical bash script for ``script_name``.

Scripts are executed rather than read, so composition is resolved at
*invocation* time (#4551): while any preset provides this script, the
canonical ``.specify/scripts/bash/<name>.sh`` is a fixed dispatcher
that resolves the live chain via ``specify preset script-chain``.
Because it is used for every chain length (including one layer), later
enable/disable/set-priority changes never require rewriting it. When
no preset provides the script any more, the bundled core script is
restored (or the generated dispatcher removed if there is no core).
"""
if script_name in _RESERVED_SCRIPT_NAMES:
raise PresetValidationError(
f"Script name '{script_name}' is reserved for the generated "
f"script continuation runtime and cannot be provided by a preset."
)
resolver = PresetResolver(self.project_root)
chain = resolver.resolve_script_chain(script_name)
scripts_dir = self.project_root / ".specify" / "scripts" / "bash"
canonical = scripts_dir / f"{script_name}.sh"

# Validate every ancestor (not just the leaf): a symlinked ``.specify``
# would otherwise redirect the writes and the cleanup unlink outside
# the project.
_ensure_safe_shared_directory(self.project_root, scripts_dir)
_ensure_safe_shared_destination(self.project_root, canonical)
runner = scripts_dir / _SCRIPT_RUNNER_NAME
_ensure_safe_shared_destination(self.project_root, runner)

# Judge "provided by a preset" from every active declaration, not the
# truncated chain: an extension's replace layer can end the chain
# above a lower-priority preset, and a later set-priority or enable
# change must still find the dispatcher already in place.
preset_roots = (
self.project_root / ".specify" / "presets",
self.project_root / ".specify" / "templates" / "overrides",
)
provided_by_preset = bool(chain) and any(
root in layer["path"].parents
for layer in resolver.collect_all_layers(script_name, "script")
for root in preset_roots
)

if provided_by_preset:
_write_shared_text(self.project_root, runner, _SCRIPT_CONTINUATION_RUNNER)
_write_shared_text(
self.project_root,
canonical,
_SCRIPT_CONTINUATION_DISPATCHER_TEMPLATE.format(
script_name=script_name, runner_name=_SCRIPT_RUNNER_NAME
),
)
if os.name != "nt":
for generated in (runner, canonical):
generated.chmod(generated.stat().st_mode | 0o111)
return

# No preset layer left: restore core, or drop a stale generated stub.
if chain:
_write_shared_text(
self.project_root, canonical, chain[-1].read_text(encoding="utf-8")
)
# Command frontmatter executes this path directly, and
# _write_shared_text writes 0644.
if os.name != "nt":
canonical.chmod(canonical.stat().st_mode | 0o111)
elif canonical.is_file():
first_line = canonical.read_text(encoding="utf-8").splitlines()[:2]
if any(_SCRIPT_DISPATCHER_MARKER in line for line in first_line):
canonical.unlink()

def _seed_constitution_from_preset(
self, manifest: PresetManifest, preset_dir: Path
) -> None:
Expand Down Expand Up @@ -702,6 +888,7 @@ def remove(self, pack_id: str) -> bool:
# entirely and _unregister_skills would restore core/extension
# content instead of a surviving lower-priority preset's override.
removed_cmd_names = set()
removed_script_names = set()
removed_constitution = any(
path.exists()
for path in (
Expand Down Expand Up @@ -740,6 +927,10 @@ def remove(self, pack_id: str) -> bool:
for alias in tmpl.get("aliases", []):
if isinstance(alias, str):
removed_cmd_names.add(alias)
if tmpl.get("type") == "script":
name = tmpl.get("name")
if isinstance(name, str):
removed_script_names.add(name)
except PresetValidationError:
# Invalid manifest — skip alias extraction; primary command
# names from registered_commands are still unregistered.
Expand Down Expand Up @@ -881,6 +1072,19 @@ def remove(self, pack_id: str) -> bool:
stacklevel=2,
)

if removed_script_names:
for script_name in removed_script_names:
try:
self._reconcile_script_chain(script_name)
except Exception as exc:
import warnings
warnings.warn(
f"Post-removal script reconciliation failed for "
f"{pack_id} script '{script_name}': {exc}. "
f"Run 'specify preset script-chain {script_name}' to diagnose.",
stacklevel=2,
)

if removed_constitution:
try:
self._reconcile_constitution()
Expand Down
71 changes: 71 additions & 0 deletions src/specify_cli/presets/_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +682,77 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]:

return layers

def resolve_script_chain(self, script_name: str) -> List[Path]:
"""Return the ordered chain of files backing a script's continuation.

Scripts are executed rather than merely read, so — unlike
templates and commands — their composition doesn't need to be
spliced into a single file ahead of time. A ``"wrap"`` script
contains a literal ``$CORE_SCRIPT`` reference that a runtime
continuation runner resolves hop by hop, so this returns the
stack of files that reference forms, in priority order, rather
than composed content.

This walks the same priority stack as ``resolve_content()`` for
``template_type="script"``: the highest-priority layer down
through the nearest layer with strategy ``"replace"``
(inclusive), which terminates the chain — only ``"replace"`` and
``"wrap"`` are valid script strategies, so a chain longer than
one entry always has a ``"wrap"`` top. Layers below the
terminating ``"replace"`` layer are never reachable and are
omitted, matching ``resolve_content()``.

Returns an empty list when the script name has no layers, or
when none of them has strategy ``"replace"`` (composition has no
base to terminate on — the same condition under which
``resolve_content()`` returns ``None``).
"""
layers = list(self.collect_all_layers(script_name, "script"))
if not any(layer["strategy"] == "replace" for layer in layers):
# collect_all_layers() only knows scripts/<name>.sh; the real
# built-in Bash assets live under scripts/bash/.
bundled = self._find_bundled_bash_script(script_name)
if bundled is not None:
layers.append(
{"path": bundled, "source": "core (bundled)", "strategy": "replace"}
)
if not layers:
return []
base_idx = next(
(i for i, layer in enumerate(layers) if layer["strategy"] == "replace"),
None,
)
if base_idx is None:
return []
chain_layers = layers[: base_idx + 1]
for layer in chain_layers:
if layer["strategy"] != "wrap":
continue
try:
body = layer["path"].read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as exc:
raise PresetValidationError(
f"Cannot read wrap script '{layer['source']}': {exc}"
) from exc
if "$CORE_SCRIPT" not in body:
raise PresetValidationError(
f"Wrap strategy in '{layer['source']}' is missing the "
f"$CORE_SCRIPT placeholder; executing it would silently "
f"drop every lower layer."
)
return [layer["path"] for layer in chain_layers]

def _find_bundled_bash_script(self, script_name: str) -> Optional[Path]:
"""Locate the built-in Bash script from the core pack or source tree."""
try:
from specify_cli import _locate_core_pack, _repo_root
except ImportError:
return None
core_pack = _locate_core_pack()
base = core_pack if core_pack is not None else _repo_root()
candidate = base / "scripts" / "bash" / f"{script_name}.sh"
return candidate if candidate.is_file() else None

def _find_bundled_core(
self,
template_name: str,
Expand Down
43 changes: 43 additions & 0 deletions src/specify_cli/presets/command_resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,49 @@
from ._commands import preset_app


@preset_app.command("script-chain", hidden=True)
def preset_script_chain(
Comment on lines +14 to +15
script_name: str = typer.Argument(
..., help="Script name to resolve (e.g., setup-plan)"
),
):
"""Print the resolved script continuation chain, one path per line.

Internal command consumed by the generated script continuation
dispatcher and runner (see ``PresetResolver.resolve_script_chain``);
not intended for interactive use. Output is the ordered chain of
file paths from highest priority to the terminating "replace" layer,
one absolute path per line, with no other output.
"""
from .. import _require_specify_project
from . import PresetResolver, PresetValidationError

if re.fullmatch(r"[a-z0-9-]+", script_name) is None:
typer.echo(
f"Error: invalid script name '{script_name}'; "
"use lowercase letters, digits, and hyphens",
err=True,
)
raise typer.Exit(1)

project_root = _require_specify_project()
resolver = PresetResolver(project_root)
try:
chain = resolver.resolve_script_chain(script_name)
except PresetValidationError as exc:
typer.echo(f"Error: {exc}", err=True)
raise typer.Exit(1)
if not chain:
typer.echo(
f"Error: could not resolve a script chain for '{script_name}'",
err=True,
)
raise typer.Exit(1)

for path in chain:
typer.echo(str(path))


@preset_app.command("resolve")
def preset_resolve(
template_name: str = typer.Argument(
Expand Down
Loading