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
6 changes: 6 additions & 0 deletions docs/stack-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (~/<name>-extensions).
# Missing = "stack". An instance
# without it whose data dir exists
# as <name>-data gets it written on
# the next command.
domain = "" # empty = port mode
dns_provider = "" # domain mode: "hetzner" or
# "cloudflare" serves HTTPS with
Expand Down
6 changes: 5 additions & 1 deletion lib/stack/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────
Expand Down
17 changes: 17 additions & 0 deletions lib/stack/stack.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "~/<name>-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 `<name>-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
Expand Down
94 changes: 94 additions & 0 deletions tests/unit/framework/test_product_name_migration.py
Original file line number Diff line number Diff line change
@@ -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 `~/<name>-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 = "~/<name>-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 `<name>-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
Loading