diff --git a/docs/stack-reference.md b/docs/stack-reference.md index 86f04a9b..d2ad3f43 100644 --- a/docs/stack-reference.md +++ b/docs/stack-reference.md @@ -1125,6 +1125,12 @@ One file, committed to the repo. User edits it directly. ```toml [core] +name = "famstack" # product name: CLI branding and + # derived paths (~/-extensions). + # Missing = "stack". An instance + # without it whose data dir exists + # as -data gets it written on + # the next command. domain = "" # empty = port mode dns_provider = "" # domain mode: "hetzner" or # "cloudflare" serves HTTPS with diff --git a/lib/stack/cli.py b/lib/stack/cli.py index abb41eed..2d7d3a77 100644 --- a/lib/stack/cli.py +++ b/lib/stack/cli.py @@ -625,12 +625,16 @@ def create_stack(repo_root: Path, instance_dir: Path | None = None) -> Stack: name = cfg.get("core", {}).get("name", "stack") data_dir = cfg.get("core", {}).get("data_dir", f"~/{name}-data") - return Stack( + stck = Stack( root=repo_root, data=Path(data_dir).expanduser(), instance_dir=instance, output=TerminalOutput(), ) + if adopted := stck.adopt_product_name(): + stck.output.step(f"stack.toml: set [core] name = \"{adopted}\", " + f"the name of the data dir {stck.data}") + return stck # ── Output formatting ──────────────────────────────────────────────────── diff --git a/lib/stack/stack.py b/lib/stack/stack.py index d27991db..3ed3a922 100644 --- a/lib/stack/stack.py +++ b/lib/stack/stack.py @@ -135,6 +135,23 @@ def product_name(self) -> str: """Product name from stack.toml [core] name, defaults to 'stack'.""" return self._cfg("core", "name", "stack") + def adopt_product_name(self) -> str | None: + """Record `[core] name` for an instance installed without one. + + The installer wrote `data_dir = "~/-data"` long before it + wrote `name`, so an installed instance's data dir carries its + name. Nothing is written without that evidence: no data dir yet, + a dir not named `-data`, or a name already set. Returns the + name written, or None. + """ + if self._cfg("core", "name") or not self.data.is_dir(): + return None + name = self.data.name.removesuffix("-data") + if not name or name == self.data.name: + return None + self._set_cfg("core", "name", name) + return name + # ── Config ──────────────────────────────────────────────────────── @property diff --git a/tests/unit/framework/test_product_name_migration.py b/tests/unit/framework/test_product_name_migration.py new file mode 100644 index 00000000..fa0bd2a4 --- /dev/null +++ b/tests/unit/framework/test_product_name_migration.py @@ -0,0 +1,94 @@ +"""Instances installed before the installer wrote `[core] name` get one. + +The product name drives the CLI's branding and the paths derived from +it, among them the default extension dir `~/-extensions`. The +installer only started writing `name` in September 2026. An older +instance has no `name`, falls back to "stack", and searches +`~/stack-extensions` while every doc tells its admin to use +`~/famstack-extensions`. + +The data dir already carries the name: the installer has always written +`data_dir = "~/-data"`. When the name is missing and that dir +exists, the next `stack` command records the name, so the instance ends +up with the config a fresh install writes (RFC-002, phase 3 gate). +""" + +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent.parent.parent +sys.path.insert(0, str(REPO / "lib")) + +from stack.cli import create_stack # noqa: E402 + +OLD_INSTALL = """\ +# stack.toml — generated by famstack installer +[core] +stack_owner = "The Simpsons" +domain = "" +data_dir = "{data_dir}" +timezone = "Europe/Berlin" + +[agent] +name = "Stacky" +""" + + +def _instance(tmp_path, data_dir: Path, create_data: bool = True) -> Path: + if create_data: + data_dir.mkdir(parents=True) + (tmp_path / "stack.toml").write_text(OLD_INSTALL.format(data_dir=data_dir)) + return tmp_path + + +def test_an_installed_instance_gets_the_name_its_data_dir_carries(tmp_path): + instance = _instance(tmp_path, tmp_path / "famstack-data") + + stck = create_stack(REPO, instance) + + assert stck.product_name() == "famstack" + assert stck.extension_dirs == [Path.home() / "famstack-extensions"] + + +def test_the_rest_of_the_file_is_left_as_the_admin_wrote_it(tmp_path): + """Only `[core]` gains a line. The `name` under `[agent]` is a + different setting and must not be taken for the product name.""" + instance = _instance(tmp_path, tmp_path / "famstack-data") + before = (instance / "stack.toml").read_text().splitlines() + + create_stack(REPO, instance) + + after = (instance / "stack.toml").read_text().splitlines() + assert [line for line in after if line not in before] == ['name = "famstack"'] + assert set(before) <= set(after) + assert after.index('name = "famstack"') == after.index("[core]") + 1 + + +def test_a_name_the_admin_set_is_kept(tmp_path): + instance = _instance(tmp_path, tmp_path / "famstack-data") + cfg = instance / "stack.toml" + cfg.write_text(cfg.read_text().replace("[core]\n", '[core]\nname = "homestack"\n')) + before = cfg.read_text() + + assert create_stack(REPO, instance).product_name() == "homestack" + assert cfg.read_text() == before + + +def test_nothing_is_written_before_there_is_an_instance(tmp_path): + """No data dir means nothing was installed there yet, so there is no + evidence of what the instance is called.""" + instance = _instance(tmp_path, tmp_path / "famstack-data", create_data=False) + before = (instance / "stack.toml").read_text() + + create_stack(REPO, instance) + + assert (instance / "stack.toml").read_text() == before + + +def test_a_data_dir_the_admin_named_freely_says_nothing(tmp_path): + """`/Volumes/SSD/family` is a location, not `-data`.""" + instance = _instance(tmp_path, tmp_path / "family") + before = (instance / "stack.toml").read_text() + + assert create_stack(REPO, instance).product_name() == "stack" + assert (instance / "stack.toml").read_text() == before