Move tool profiles to a root file and version the release as 0.5.0

- Tool profiles come from a committed `.napseer-tools.json` at the
  project root (schema napseer.tools.v1). The unreleased tool_profiles
  field in .napseer/project.json is removed: released clients reject
  unknown locator fields, and 0.4.0 treats any extra file inside .napseer/
  as private state needing migration (a test loads the released 0.4.0
  check from c427e24 to prove the root file is invisible to it).
  Environment still overrides; unusable files warn and fall back to core.
- CLI release 0.5.0 with distribution contract 2026-09-29; the minimum
  supported contract stays 2026-09-10. The newer-contract rejection test
  now derives its date from the installer's own contract.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
HumbleTurtle
2026-09-28 23:33:22 -03:00
co-authored by Claude Opus 5.5
parent d86610da8e
commit 611a4571f3
5 changed files with 283 additions and 61 deletions
+2 -2
View File
@@ -66,8 +66,8 @@ RUNTIME_SCRIPT_NAMES = (
"napseer_mcp_supervisor.py",
)
MAX_SERVICE_LOG_BYTES = int(os.environ.get("NAPSEER_MAX_SERVICE_LOG_BYTES", str(5 * 1024 * 1024)))
CLI_RELEASE_VERSION = "0.4.0"
CLI_DISTRIBUTION_CONTRACT_VERSION = "2026-09-10"
CLI_RELEASE_VERSION = "0.5.0"
CLI_DISTRIBUTION_CONTRACT_VERSION = "2026-09-29"
# Older installers have a fixed asset list and would omit the credentials module.
# Reject them before activation; the published bootstrap performs the upgrade.
CLI_MINIMUM_CONTRACT_VERSION = "2026-09-10"
+79 -35
View File
@@ -326,9 +326,12 @@ AUTH_PATH = pathlib.Path(os.environ.get("NAPSEER_AUTH_FILE", PROJECT_DIR / "auth
PROJECT_LOCATOR_SCHEMA = "napseer.project.v1"
PROJECT_LOCATOR_FILENAME = "project.json"
PROJECT_LOCATOR_FIELDS = {"schema", "base_url", "project_id", "project_slug"}
# Optional, non-identity locator preferences. Values are validated where
# they are consumed so an unknown preference never blocks project identity.
PROJECT_LOCATOR_OPTIONAL_FIELDS = {"tool_profiles"}
# Committed, non-identity tool preferences live at the project root, outside
# .napseer/: the locator schema stays strict and released clients that treat
# other .napseer/ entries as private state awaiting migration never see it.
PROJECT_TOOLS_SCHEMA = "napseer.tools.v1"
PROJECT_TOOLS_FILENAME = ".napseer-tools.json"
PROJECT_TOOLS_MAX_BYTES = 4096
GATEWAY_AUTH_PATH = AUTH_DIR / "gateway-auth.json"
VAULT_PATH = AUTH_DIR / "vault.json"
LOCAL_VAULT_KEY_PATH = AUTH_DIR / "vault.local-key"
@@ -874,7 +877,7 @@ def validate_project_locator(data):
"project_locator_invalid",
".napseer/project.json must contain one JSON object.",
)
if not PROJECT_LOCATOR_FIELDS <= set(data) or set(data) - PROJECT_LOCATOR_FIELDS - PROJECT_LOCATOR_OPTIONAL_FIELDS:
if set(data) != PROJECT_LOCATOR_FIELDS:
raise SafeToolError(
"project_locator_invalid",
".napseer/project.json has unsupported or missing fields. Regenerate it with a current nap CLI.",
@@ -922,15 +925,12 @@ def validate_project_locator(data):
".napseer/project.json project_slug is invalid.",
)
locator = {
return {
"schema": PROJECT_LOCATOR_SCHEMA,
"base_url": raw_base_url,
"project_id": project_id,
"project_slug": project_slug,
}
if "tool_profiles" in data:
locator["tool_profiles"] = data["tool_profiles"]
return locator
def load_project_locator():
@@ -1048,9 +1048,6 @@ def write_project_locator(project, *, base_url=None):
"project_locator_mismatch",
"Local project credentials do not match the committed .napseer/project.json locator. Attach the declared project or intentionally update the locator before continuing.",
)
if existing and "tool_profiles" in existing:
# Preserve the repository's committed preference on rewrite.
desired["tool_profiles"] = existing["tool_profiles"]
ensure_project_locator_ignore_policy()
path = project_locator_path()
@@ -19723,31 +19720,77 @@ REMOVED_TOOL_REPLACEMENTS = {
CONFIGURED_TOOL_PROFILES = None
TOOL_PROFILE_LOCATOR_MAX_ITEMS = 16
TOOL_PROFILE_FILE_MAX_ITEMS = 16
def locator_tool_profiles():
"""Return (profiles, declared, warnings) from the committed project locator.
def project_tools_path():
"""Committed tool preferences at the project root that owns the locator."""
return PROJECT_DIR.parent / PROJECT_TOOLS_FILENAME
Unknown or malformed entries are ignored with a warning; a locator that
cannot be read yields no profiles here (identity errors surface elsewhere).
def read_project_tools_file():
"""Return the parsed committed tools file, None when absent.
Raises ValueError with a safe message when the file cannot be used. The
file must be a bounded regular JSON file inside the resolved project root.
"""
import stat
path = project_tools_path()
try:
if not path.exists() and not path.is_symlink():
return None
if path.is_symlink():
raise ValueError(f"{PROJECT_TOOLS_FILENAME} must be a regular file, not a symlink.")
root = path.parent.resolve()
if path.resolve().parent != root:
raise ValueError(f"{PROJECT_TOOLS_FILENAME} must stay inside the project root.")
fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0))
with os.fdopen(fd, "rb") as source:
status = os.fstat(source.fileno())
if not stat.S_ISREG(status.st_mode):
raise ValueError(f"{PROJECT_TOOLS_FILENAME} must be a regular file.")
if status.st_size > PROJECT_TOOLS_MAX_BYTES:
raise ValueError(f"{PROJECT_TOOLS_FILENAME} exceeds {PROJECT_TOOLS_MAX_BYTES} bytes.")
body = source.read(PROJECT_TOOLS_MAX_BYTES + 1)
except OSError:
raise ValueError(f"{PROJECT_TOOLS_FILENAME} could not be read.") from None
if len(body) > PROJECT_TOOLS_MAX_BYTES:
raise ValueError(f"{PROJECT_TOOLS_FILENAME} exceeds {PROJECT_TOOLS_MAX_BYTES} bytes.")
try:
data = json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, ValueError, RecursionError):
raise ValueError(f"{PROJECT_TOOLS_FILENAME} is not valid UTF-8 JSON.") from None
if not isinstance(data, dict):
raise ValueError(f"{PROJECT_TOOLS_FILENAME} must contain one JSON object.")
if data.get("schema") != PROJECT_TOOLS_SCHEMA:
raise ValueError(f"{PROJECT_TOOLS_FILENAME} schema must be {PROJECT_TOOLS_SCHEMA}.")
if not isinstance(data.get("tool_profiles"), list):
raise ValueError(f"{PROJECT_TOOLS_FILENAME} tool_profiles must be an array of profile names.")
return data
def project_file_tool_profiles():
"""Return (profiles, applied, warnings) from the committed tools file.
An unusable file is ignored with a warning (core only); unknown profile
names are dropped with a warning. The strict project locator is never
consulted for tool preferences.
"""
try:
locator = load_project_locator()
except SafeToolError as exc:
return set(), False, [{"code": "project_locator_unreadable", "message": exc.safe_message}]
if not locator or "tool_profiles" not in locator:
return set(), False, []
raw = locator["tool_profiles"]
if not isinstance(raw, list):
return set(), True, [{
"code": "invalid_tool_profiles",
"message": ".napseer/project.json tool_profiles must be an array of profile names; it was ignored.",
data = read_project_tools_file()
except ValueError as exc:
return set(), False, [{
"code": "tools_file_invalid",
"message": f"{exc} It was ignored; only core tools are enabled unless NAPSEER_TOOL_PROFILES is set.",
}]
if data is None:
return set(), False, []
raw = data["tool_profiles"]
known = {"all"} | set(TOOL_PROFILES)
profiles = set()
unknown = []
for value in raw[:TOOL_PROFILE_LOCATOR_MAX_ITEMS]:
for value in raw[:TOOL_PROFILE_FILE_MAX_ITEMS]:
name = value.strip().lower() if isinstance(value, str) else None
if name in known:
profiles.add(name)
@@ -19757,14 +19800,14 @@ def locator_tool_profiles():
if unknown:
warnings.append({
"code": "unknown_tool_profile",
"message": ".napseer/project.json tool_profiles contains unknown profile names that were ignored. Supported profiles: "
"message": f"{PROJECT_TOOLS_FILENAME} tool_profiles contains unknown profile names that were ignored. Supported profiles: "
+ ", ".join(sorted(TOOL_PROFILES)) + ".",
"ignored": unknown,
})
if len(raw) > TOOL_PROFILE_LOCATOR_MAX_ITEMS:
if len(raw) > TOOL_PROFILE_FILE_MAX_ITEMS:
warnings.append({
"code": "tool_profiles_truncated",
"message": f".napseer/project.json tool_profiles was truncated to {TOOL_PROFILE_LOCATOR_MAX_ITEMS} entries.",
"message": f"{PROJECT_TOOLS_FILENAME} tool_profiles was truncated to {TOOL_PROFILE_FILE_MAX_ITEMS} entries.",
})
return profiles, True, warnings
@@ -19773,7 +19816,7 @@ def tool_profile_configuration():
"""Return (profiles, source, warnings).
Precedence: in-process override, then a non-empty NAPSEER_TOOL_PROFILES,
then the committed locator's optional tool_profiles, then core only.
then the committed .napseer-tools.json at the project root, then core only.
"""
if CONFIGURED_TOOL_PROFILES is not None:
configured = set(CONFIGURED_TOOL_PROFILES)
@@ -19786,8 +19829,8 @@ def tool_profile_configuration():
source = "environment"
warnings = []
else:
configured, declared, warnings = locator_tool_profiles()
source = "project_locator" if declared else "default"
configured, applied, warnings = project_file_tool_profiles()
source = "tools_file" if applied else "default"
return configured, source, warnings
unknown = set(configured) - ({"all"} | set(TOOL_PROFILES))
if unknown:
@@ -20050,7 +20093,8 @@ def contract_profile(args=None):
"active": sorted(active_profiles),
"source": profile_source,
"warnings": profile_warnings,
"configuration": "NAPSEER_TOOL_PROFILES, else .napseer/project.json tool_profiles",
"configuration": "NAPSEER_TOOL_PROFILES, else .napseer-tools.json tool_profiles at the project root, else core",
"tools_file": str(project_tools_path()),
"available": {
name: sorted(tool_names) for name, tool_names in TOOL_PROFILES.items()
},
@@ -20926,7 +20970,7 @@ def _call_tool_impl_dispatch(name, args):
"tool_profile_required",
f"{canonical_name} requires the '{profile}' tool profile. Set "
f"NAPSEER_TOOL_PROFILES={profile} (or include it in the comma-separated list) and restart the MCP server, "
f"or add it to tool_profiles in .napseer/project.json.",
f"or add it to tool_profiles in .napseer-tools.json at the project root.",
)
if name not in PUBLIC_TOOLS and name in PROTECTED_TOOLS:
require_unlocked(name)
+10 -4
View File
@@ -1,4 +1,5 @@
import importlib.util
import datetime
import hashlib
import json
import pathlib
@@ -513,11 +514,16 @@ def test_manifest_accepts_a_future_compatible_release():
def test_manifest_rejects_a_minimum_contract_newer_than_installer():
module = load_installer()
manifest, _payloads = fake_bundle(module)
manifest["contract"]["current"] = "2026-09-11"
manifest["contract"]["minimum_supported"] = "2026-09-11"
# One day past this installer's own contract, whatever it currently is.
newer = (
datetime.date.fromisoformat(module.CLI_DISTRIBUTION_CONTRACT_VERSION)
+ datetime.timedelta(days=1)
).isoformat()
manifest["contract"]["current"] = newer
manifest["contract"]["minimum_supported"] = newer
for item in manifest["items"]:
item["contract_version"] = "2026-09-11"
item["minimum_contract_version"] = "2026-09-11"
item["contract_version"] = newer
item["minimum_contract_version"] = newer
try:
module.validated_manifest_items(manifest)
+140 -20
View File
@@ -1,5 +1,5 @@
"""Regressions for write idempotency, stale local reads, atomic claim-next,
indexed link counts, and locator-declared tool profiles."""
indexed link counts, and committed .napseer-tools.json tool profiles."""
import importlib.util
import io
@@ -406,7 +406,7 @@ def test_local_discovery_rows_report_indexed_link_counts(mod):
assert "links_count" not in summary
# -- 5. Tool profiles from the committed locator ---------------------------
# -- 5. Tool profiles from the committed root .napseer-tools.json ----------
def write_locator(mod, **extra):
@@ -421,33 +421,153 @@ def write_locator(mod, **extra):
}))
def test_locator_tool_profiles_apply_when_env_unset(mod, monkeypatch):
mod.CONFIGURED_TOOL_PROFILES = None
assert mod.configured_tool_profiles() == set()
def write_tools_file(mod, profiles=None, *, raw=None):
path = mod.project_tools_path()
path.parent.mkdir(parents=True, exist_ok=True)
if raw is None:
raw = json.dumps({"schema": "napseer.tools.v1", "tool_profiles": profiles}) + "\n"
path.write_bytes(raw if isinstance(raw, bytes) else raw.encode("utf-8"))
return path
write_locator(mod, tool_profiles=["workflow", "operations", "bogus"])
assert mod.load_project_locator()["project_slug"] == "example"
assert mod.configured_tool_profiles() == {"workflow", "operations"}
assert "nap_kanban_claim_next" in mod.enabled_tool_names()
apropos = mod.explore_tools({})
assert apropos["profile_source"] == "project_locator"
assert apropos["warnings"][0]["code"] == "unknown_tool_profile"
assert apropos["warnings"][0]["ignored"] == ["bogus"]
def doctor_warnings(mod):
mod.BASE_URL = "https://api.example.test"
mod.local_anchor_integrity = lambda: {}
mod.index_status = lambda args: {}
doctor = mod.doctor({})
assert doctor["warnings"][0]["code"] == "unknown_tool_profile"
return mod.doctor({}).get("warnings", [])
def test_tools_file_profiles_apply_when_env_unset(mod):
mod.CONFIGURED_TOOL_PROFILES = None
write_locator(mod)
assert mod.tool_profile_configuration() == (set(), "default", [])
assert mod.project_tools_path() == mod.project_locator_path().parent.parent / ".napseer-tools.json"
# The retired in-.napseer location is never read.
(mod.project_locator_path().parent / "tools.json").write_text(
json.dumps({"schema": "napseer.tools.v1", "tool_profiles": ["workflow"]}))
assert mod.tool_profile_configuration() == (set(), "default", [])
write_tools_file(mod, ["workflow", " Operations "])
assert mod.tool_profile_configuration() == ({"workflow", "operations"}, "tools_file", [])
assert "nap_kanban_claim_next" in mod.enabled_tool_names()
apropos = mod.explore_tools({})
assert apropos["profile_source"] == "tools_file"
assert apropos["active_profiles"] == ["operations", "workflow"]
assert "warnings" not in apropos
contract = mod.contract_profile({"include_tools": False})["tool_profiles"]
assert contract["source"] == "tools_file"
assert contract["tools_file"] == str(mod.project_tools_path())
assert doctor_warnings(mod) == []
# The committed locator still carries identity only.
assert set(mod.load_project_locator()) == {"schema", "base_url", "project_id", "project_slug"}
def test_env_overrides_tools_file(mod, monkeypatch):
mod.CONFIGURED_TOOL_PROFILES = None
write_tools_file(mod, ["workflow"])
monkeypatch.setenv("NAPSEER_TOOL_PROFILES", "maintenance")
assert mod.configured_tool_profiles() == {"maintenance"}
assert mod.tool_profile_configuration() == ({"maintenance"}, "environment", [])
assert "nap_kanban_claim_next" not in mod.enabled_tool_names()
assert mod.explore_tools({})["profile_source"] == "environment"
monkeypatch.delenv("NAPSEER_TOOL_PROFILES")
write_locator(mod, tool_profiles="workflow")
assert mod.configured_tool_profiles() == set()
assert mod.explore_tools({})["warnings"][0]["code"] == "invalid_tool_profiles"
# A non-empty environment value wins without consulting a broken file.
write_tools_file(mod, raw="{not json")
assert mod.tool_profile_configuration() == ({"maintenance"}, "environment", [])
# An empty or blank value is unset: the committed file applies again.
write_tools_file(mod, ["workflow"])
monkeypatch.setenv("NAPSEER_TOOL_PROFILES", " ")
assert mod.tool_profile_configuration() == ({"workflow"}, "tools_file", [])
def test_tools_file_unknown_profile_names_are_ignored_with_warning(mod):
mod.CONFIGURED_TOOL_PROFILES = None
write_tools_file(mod, ["workflow", "bogus", 7])
profiles, source, warnings = mod.tool_profile_configuration()
assert (profiles, source) == ({"workflow"}, "tools_file")
assert [w["code"] for w in warnings] == ["unknown_tool_profile"]
assert warnings[0]["ignored"] == ["bogus", "7"]
apropos = mod.explore_tools({})
assert apropos["warnings"][0]["code"] == "unknown_tool_profile"
assert [w["code"] for w in doctor_warnings(mod)] == ["unknown_tool_profile"]
write_tools_file(mod, ["workflow"] * 20)
assert [w["code"] for w in mod.tool_profile_configuration()[2]] == ["tool_profiles_truncated"]
@pytest.mark.parametrize(
"case",
["invalid_json", "not_object", "wrong_schema", "missing_schema", "profiles_not_list",
"oversized", "not_utf8", "symlink", "directory"],
)
def test_unusable_tools_file_warns_and_falls_back_to_core(mod, tmp_path_factory, case):
mod.CONFIGURED_TOOL_PROFILES = None
marker = "synthetic-marker-not-echoed"
raw = {
"invalid_json": '{"schema": "napseer.tools.v1", "tool_profiles": ["workflow"], ' + marker,
"not_object": json.dumps(["workflow", marker]),
"wrong_schema": json.dumps({"schema": "napseer.tools.v9", "tool_profiles": ["workflow"]}),
"missing_schema": json.dumps({"tool_profiles": ["workflow"]}),
"profiles_not_list": json.dumps({"schema": "napseer.tools.v1", "tool_profiles": "workflow"}),
"oversized": json.dumps({"schema": "napseer.tools.v1", "tool_profiles": ["workflow"]}) + " " * 4096,
"not_utf8": b'{"schema": "napseer.tools.v1", "tool_profiles": ["\xff"]}',
}.get(case)
path = mod.project_tools_path()
path.parent.mkdir(parents=True, exist_ok=True)
if case == "symlink":
outside = tmp_path_factory.mktemp("outside-project") / ".napseer-tools.json"
outside.write_text(json.dumps({"schema": "napseer.tools.v1", "tool_profiles": ["all"]}))
path.symlink_to(outside)
elif case == "directory":
path.mkdir()
else:
write_tools_file(mod, raw=raw)
profiles, source, warnings = mod.tool_profile_configuration()
assert (profiles, source) == (set(), "default")
assert [w["code"] for w in warnings] == ["tools_file_invalid"]
assert marker not in warnings[0]["message"]
assert mod.enabled_tool_names() == set(mod.CORE_TOOL_NAMES)
assert mod.explore_tools({})["warnings"][0]["code"] == "tools_file_invalid"
assert [w["code"] for w in doctor_warnings(mod)] == ["tools_file_invalid"]
with pytest.raises(mod.SafeToolError) as excinfo:
mod._call_tool_impl_dispatch("nap_kanban_claim_next", {})
assert excinfo.value.code == "tool_profile_required"
assert ".napseer-tools.json" in excinfo.value.safe_message
def test_tools_file_parser_recursion_is_contained(mod, monkeypatch):
# Older Python JSON decoders raise RecursionError on deep nesting that
# still fits the size bound; it must degrade to core, not fail every call.
mod.CONFIGURED_TOOL_PROFILES = None
write_tools_file(mod, ["workflow"])
def too_deep(*_args, **_kwargs):
raise RecursionError("synthetic")
with monkeypatch.context() as patched:
patched.setattr(mod.json, "loads", too_deep)
profiles, source, warnings = mod.tool_profile_configuration()
assert (profiles, source) == (set(), "default")
assert [w["code"] for w in warnings] == ["tools_file_invalid"]
def test_locator_tool_profiles_field_is_still_rejected(mod):
# tool_profiles was never a released locator field: the locator keeps its
# exact-field schema and the field is neither accepted nor applied.
mod.CONFIGURED_TOOL_PROFILES = None
write_locator(mod, tool_profiles=["workflow"])
with pytest.raises(mod.SafeToolError) as excinfo:
mod.load_project_locator()
assert excinfo.value.code == "project_locator_invalid"
assert "unsupported or missing fields" in excinfo.value.safe_message
with pytest.raises(mod.SafeToolError) as excinfo:
mod.write_project_locator({"id": "11111111-1111-4111-8111-111111111111", "slug": "example"},
base_url="https://api.example.test")
assert excinfo.value.code == "project_locator_invalid"
assert "tool_profiles" in json.loads(mod.project_locator_path().read_text())
assert mod.tool_profile_configuration() == (set(), "default", [])
assert "nap_kanban_claim_next" not in mod.enabled_tool_names()
# -- 6. Change feed cursor --------------------------------------------------
+52
View File
@@ -12,6 +12,9 @@ sys.path.insert(0, str(SCRIPTS))
from napseer_credentials import CredentialStore, atomic_json, read_json
from test_user_credentials import provider, worker, outcome
# Released CLI 0.4.0 source revision (pinned by the Napseer deploy workflow).
RELEASED_0_4_0 = 'c427e2432ac85038a4f00b2b888bbdb7c851ada1'
@pytest.fixture
def worktrees(tmp_path):
@@ -158,3 +161,52 @@ def test_private_state_cannot_be_redirected_into_checkout(worktrees, monkeypatch
main,_=worktrees
monkeypatch.setenv('NAPSEER_USER_DATA_DIR',str(main/'private-state'))
with pytest.raises(CredentialError,match='outside'):WorkspacePaths(main)
def test_root_tools_file_resolves_like_locator_and_is_invisible_to_migration(worktrees, monkeypatch):
from napseer_credentials import WorkspacePaths
main, _ = worktrees
monkeypatch.delenv('NAPSEER_TOOL_PROFILES', raising=False)
tools = main/'.napseer-tools.json'
committed = b'{"schema": "napseer.tools.v1", "tool_profiles": ["workflow"]}\n'
tools.write_bytes(committed)
sub = main/'nested'; sub.mkdir()
# A worker started below the project root finds the file at the root that
# owns .napseer/project.json, the same way it finds the locator.
expression = ("{'path':str(m.project_tools_path()),'profiles':sorted(m.tool_profile_configuration()[0]),"
"'source':m.tool_profile_configuration()[1],"
"'pending':sorted(p.name for p in m.WORKSPACE.migration_sources())}")
result = outcome(worker(sub, expression))['result']
assert result == {'path': str(tools), 'profiles': ['workflow'], 'source': 'tools_file', 'pending': []}
assert set(p.name for p in (main/'.napseer').iterdir()) <= {'project.json', '.gitignore'}
paths = WorkspacePaths(main)
assert paths.migration_sources() == []
paths.require_migrated()
paths.migrate()
assert tools.read_bytes() == committed
def test_root_tools_file_is_invisible_to_released_0_4_0_migration_check(worktrees, tmp_path):
"""CLI 0.4.0 treats any extra .napseer/ entry as private state to migrate."""
import importlib.util
released = subprocess.run(
['git', '-C', str(SCRIPTS.parent.parent), 'show', f'{RELEASED_0_4_0}:resources/scripts/napseer_credentials.py'],
capture_output=True)
if released.returncode != 0:
pytest.skip(f'released 0.4.0 source {RELEASED_0_4_0} is not in this checkout')
source = tmp_path/'napseer_credentials_0_4_0.py'
source.write_bytes(released.stdout)
spec = importlib.util.spec_from_file_location('napseer_credentials_0_4_0', source)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
main, _ = worktrees
(main/'.napseer-tools.json').write_text('{"schema": "napseer.tools.v1", "tool_profiles": ["workflow"]}\n')
paths = module.WorkspacePaths(main)
assert paths.migration_sources() == []
paths.require_migrated()
# Control: the same file inside .napseer/ would block 0.4.0 clients.
(main/'.napseer/tools.json').write_text('{}\n')
assert [p.name for p in paths.migration_sources()] == ['tools.json']
with pytest.raises(module.CredentialError):
paths.require_migrated()