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
14 changes: 13 additions & 1 deletion docs/reference/presets.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,21 @@ Shows detailed information about an installed or available preset, including its

```bash
specify preset resolve <name>
specify preset resolve <name> --kind script
specify preset resolve <name> --kind script --output <path>
```

Shows which file will be used for a given name by tracing the full resolution stack. Useful for debugging when multiple presets provide the same file.
Shows which file will be used for a given name by tracing the full resolution
stack. By default, dotted names resolve as commands and other names resolve as
templates. Use `--kind script` to inspect a script artifact, including a
manifest-declared file such as an `.mjs` module.

For a script that uses a composition strategy, the reported layer path is not
the composed output. Pass `--output <path>` to write the effective script to
that location. The command creates missing parent directories and overwrites
an existing destination file. Relative module imports in the written output
resolve from the output file's directory; referenced files are not copied.
`--output` is only valid with `--kind script`.

## Enable / Disable a Preset

Expand Down
72 changes: 66 additions & 6 deletions src/specify_cli/presets/command_resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from __future__ import annotations

import re
from pathlib import Path
from typing import Literal

import typer
from rich.markup import escape as _escape_markup
Expand All @@ -16,12 +18,26 @@ def preset_resolve(
template_name: str = typer.Argument(
..., help="Template name to resolve (e.g., spec-template)"
),
kind: Literal["command", "script", "template"] | None = typer.Option(
None,
"--kind",
help="Artifact kind (defaults to command for dotted names, template otherwise)",
),
output: Path | None = typer.Option( # noqa: B008
None,
"--output",
help=(
"Write the effective script to this path "
"(required to materialize composition)"
),
),
):
"""Show which template will be resolved for a given name."""
"""Show which artifact wins, or materialize an effective script."""
from .. import _require_specify_project
from . import PresetResolver

is_command = "." in template_name
template_type = kind or ("command" if "." in template_name else "template")
is_command = template_type == "command"
valid_name = (
re.fullmatch(r"[a-z0-9-]+(?:\.[a-z0-9-]+)+", template_name)
if is_command
Expand All @@ -36,9 +52,49 @@ def preset_resolve(
)
raise typer.Exit(1)

if output is not None and template_type != "script":
console.print("[red]Error:[/red] --output can only be used with --kind script")
raise typer.Exit(1)

project_root = _require_specify_project()
resolver = PresetResolver(project_root)
template_type = "command" if is_command else "template"

if output is not None:
try:
content = resolver.resolve_content(template_name, "script")
Comment on lines +62 to +64
except Exception as exc:
console.print(
f"[red]Error:[/red] Could not resolve script: "
f"{_escape_markup(str(exc))}"
)
raise typer.Exit(1) from exc
if content is None:
console.print(
f"[yellow]{_escape_markup(str(template_name))}[/yellow]: "
"not found or cannot be composed"
)
raise typer.Exit(1)

output_path = output.expanduser()
if not output_path.is_absolute():
output_path = Path.cwd() / output_path
output_path = output_path.resolve()
try:
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(content, encoding="utf-8")
except OSError as exc:
console.print(
f"[red]Error:[/red] Could not write script to "
f"{_escape_markup(str(output_path))}: {_escape_markup(str(exc))}"
)
raise typer.Exit(1) from exc

console.print(
f" [bold]{_escape_markup(str(template_name))}[/bold]: "
f"{_escape_markup(str(output_path))}"
)
console.print(" [dim](effective script written to --output path)[/dim]")
return

layers = resolver.collect_all_layers(template_name, template_type)
safe_template_name = _escape_markup(str(template_name))
Expand All @@ -56,9 +112,7 @@ def preset_resolve(
f"{_escape_markup(str(display_layer['source']))})[/dim]"
)

has_composition = layers[0]["strategy"] != "replace" and any(
layer["strategy"] != "replace" for layer in layers
)
has_composition = any(layer["strategy"] != "replace" for layer in layers)
if has_composition:
# Verify composition is actually possible
try:
Expand All @@ -77,6 +131,12 @@ def preset_resolve(
console.print(
" [dim]Final output is composed from multiple preset layers; the path above is the highest-priority contributing layer.[/dim]"
)
if template_type == "script":
console.print(
" [dim]Use --output PATH to write the effective composed "
"script. Relative imports in the output resolve from that "
"file's directory.[/dim]"
)
console.print("\n [bold]Composition chain:[/bold]")
# Compute the effective base: first replace layer scanning from
# highest priority (matching resolve_content top-down logic).
Expand Down
147 changes: 147 additions & 0 deletions tests/specify_cli/presets/test_command_resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,48 @@ def _install(
manager.install_from_directory(src, "9.9.9", priority)
return manager

def _install_script(
self,
temp_dir,
project_dir,
content,
*,
strategy="replace",
pack_id="script-pack",
priority=10,
):
"""Install a manifest-declared JavaScript script artifact."""
src = temp_dir / f"src-{pack_id}"
(src / "adapters").mkdir(parents=True)
(src / "adapters" / "page-adapter.mjs").write_text(content)
(src / "preset.yml").write_text(
yaml.dump(
{
"schema_version": "1.0",
"preset": {
"id": pack_id,
"name": pack_id,
"version": "1.0.0",
"description": "JavaScript script fixture",
},
"requires": {"speckit_version": ">=0.0.1"},
"provides": {
"templates": [
{
"type": "script",
"name": "designer-page-adapter",
"file": "adapters/page-adapter.mjs",
"strategy": strategy,
}
]
},
}
)
)
manager = PresetManager(project_dir)
manager.install_from_directory(src, "9.9.9", priority)
return manager

def _invoke(self, project_dir, args):
from unittest.mock import patch

Expand Down Expand Up @@ -106,6 +148,111 @@ def test_resolve_accepts_dotted_command_name(self, project_dir):
assert result.exit_code == 0, (result.output, result.exception)
assert "constitution.md" in "".join(strip_ansi(result.output).split())

def test_resolve_script_kind_finds_manifest_declared_module(
self, temp_dir, project_dir
):
self._install_script(temp_dir, project_dir, "export default 'adapter';\n")

default_result = self._invoke(
project_dir, ["preset", "resolve", "designer-page-adapter"]
)
assert default_result.exit_code == 0
assert "not found" in strip_ansi(default_result.output)

result = self._invoke(
project_dir,
["preset", "resolve", "designer-page-adapter", "--kind", "script"],
)

assert result.exit_code == 0, (result.output, result.exception)
output = "".join(strip_ansi(result.output).split())
expected_path = (
project_dir / ".specify/presets/script-pack/adapters/page-adapter.mjs"
)
assert str(expected_path) in output

def test_resolve_materializes_composed_script_at_requested_path(
self, temp_dir, project_dir
):
self._install_script(
temp_dir,
project_dir,
"import './helper.mjs';\n$CORE_SCRIPT\n",
strategy="wrap",
pack_id="wrapper-pack",
priority=5,
)
self._install_script(
temp_dir,
project_dir,
"export default 'base';\n",
pack_id="base-pack",
priority=10,
)
output_path = project_dir / "generated" / "adapter.mjs"

result = self._invoke(
project_dir,
[
"preset",
"resolve",
"designer-page-adapter",
"--kind",
"script",
"--output",
str(output_path),
],
)

assert result.exit_code == 0, (result.output, result.exception)
assert output_path.read_text() == (
"import './helper.mjs';\nexport default 'base';\n\n"
)
assert str(output_path.resolve()) in "".join(strip_ansi(result.output).split())

def test_resolve_script_composition_without_output_explains_materialization(
self, temp_dir, project_dir
):
self._install_script(
temp_dir,
project_dir,
"$CORE_SCRIPT\n",
strategy="wrap",
pack_id="wrapper-pack",
priority=5,
)
self._install_script(
temp_dir,
project_dir,
"export default 'base';\n",
pack_id="base-pack",
priority=10,
)

result = self._invoke(
project_dir,
["preset", "resolve", "designer-page-adapter", "--kind", "script"],
)

assert result.exit_code == 0, (result.output, result.exception)
output = strip_ansi(result.output)
assert "Use--outputPATH" in "".join(output.split())
compact_output = "".join(output.split())
assert (
"Relativeimportsintheoutputresolvefromthatfile'sdirectory" in compact_output
)

def test_resolve_output_requires_script_kind(self, project_dir):
result = self._invoke(
project_dir,
["preset", "resolve", "spec-template", "--output", "out.mjs"],
)

assert result.exit_code == 1
assert "--output can only be used with --kind script" in strip_ansi(
result.output
)

def test_resolve_rejects_empty_command_segments(self, project_dir):
"""Dotted command identifiers cannot contain empty path-like segments."""
result = self._invoke(
Expand Down
Loading