Skip to content

Commit 879073a

Browse files
author
Aegis Stack
committed
v0.6.11-rc2
1 parent bc0f274 commit 879073a

7 files changed

Lines changed: 157 additions & 6 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ Each generated project includes:
3232

3333
## Installation
3434

35-
**Current Version**: 0.6.11rc1
35+
**Current Version**: 0.6.11rc2
3636

3737
```bash
3838
pip install aegis-stack

aegis/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
Aegis Stack CLI - Component generation and project management tools.
33
"""
44

5-
__version__ = "0.6.11rc1"
5+
__version__ = "0.6.11rc2"

aegis/commands/update.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,72 @@
3838
from ..i18n import t
3939

4040

41+
def _detect_existing_features(target_path: Path) -> dict[str, bool]:
42+
"""Reconstruct ``include_*`` and sub-feature flags from project structure.
43+
44+
Older template versions didn't have today's full set of questions in
45+
``copier.yml`` (e.g. ``include_insights`` was added after 0.6.10), so
46+
those flags are missing from older ``.copier-answers.yml`` files.
47+
During ``aegis update -y``, copier silently uses the template's
48+
``default: false`` for any missing flag — which means a project that
49+
*clearly* has ``app/services/insights/`` on disk gets re-rendered as
50+
if it never had insights, deleting service files and breaking the
51+
project.
52+
53+
To prevent that, we walk the project structure and re-derive a flag
54+
set from what's actually installed. The caller persists those inferred
55+
values into ``.copier-answers.yml`` BEFORE invoking copier update, so
56+
copier reads them as part of the project's stored answers instead of
57+
falling back to template defaults for newly-added questions. Writing
58+
them to the answers file (rather than passing via
59+
``run_update(data=...)``) means every downstream step in the same
60+
update run — copier render, ``cleanup_components``,
61+
``sync_template_changes``, ``run_post_generation_tasks`` — sees one
62+
consistent picture. Earlier iterations of this fix passed the flags
63+
only via ``data=`` and ``cleanup_components`` then re-read the stale
64+
answers and deleted service files anyway.
65+
"""
66+
app = target_path / "app"
67+
68+
# Service directory presence → include_<service>
69+
service_flags = {
70+
"include_auth": app / "services" / "auth",
71+
"include_ai": app / "services" / "ai",
72+
"include_comms": app / "services" / "comms",
73+
"include_insights": app / "services" / "insights",
74+
"include_payment": app / "services" / "payment",
75+
}
76+
77+
# Component directory/file presence → include_<component>
78+
component_flags = {
79+
"include_database": app / "core" / "db.py",
80+
"include_redis": app / "components" / "redis",
81+
"include_worker": app / "components" / "worker",
82+
"include_scheduler": app / "components" / "scheduler",
83+
}
84+
85+
detected: dict[str, bool] = {}
86+
for flag, path in {**service_flags, **component_flags}.items():
87+
if path.exists():
88+
detected[flag] = True
89+
90+
# Insights sub-flags — the collector files alone aren't a reliable
91+
# signal because older template versions shipped them all
92+
# unconditionally. The actual signal of "this source is wired up" is
93+
# whether ``collector_service.py`` registers it. Using that here means
94+
# we won't resurrect collectors the user never opted into AND we won't
95+
# tear down collectors they actively use.
96+
collector_service = app / "services" / "insights" / "collector_service.py"
97+
if collector_service.exists():
98+
service_src = collector_service.read_text()
99+
detected["insights_github"] = "GitHubTrafficCollector" in service_src
100+
detected["insights_pypi"] = "PyPICollector" in service_src
101+
detected["insights_plausible"] = "PlausibleCollector" in service_src
102+
detected["insights_reddit"] = "RedditCollector" in service_src
103+
104+
return detected
105+
106+
41107
def _get_template_changed_files(
42108
template_root: Path,
43109
from_ref: str,
@@ -358,6 +424,88 @@ def update_command(
358424
target_ref,
359425
)
360426

427+
# Persist feature flags inferred from project structure into the
428+
# answers file BEFORE copier runs. Older answers files are missing
429+
# questions that were added later (e.g. ``include_insights`` was
430+
# added after 0.6.10), and ``defaults=True`` would silently use the
431+
# template's ``default: false`` for those — wiping service files
432+
# the user is actively using. By writing the inferred flags into
433+
# ``.copier-answers.yml`` first, every downstream step (copier
434+
# render, cleanup_components, sync_template_changes,
435+
# post_generation_tasks) sees the correct state.
436+
# See ``_detect_existing_features`` for full reasoning + scope.
437+
detected_flags = _detect_existing_features(target_path)
438+
if detected_flags:
439+
answers_path = target_path / ".copier-answers.yml"
440+
if answers_path.exists():
441+
with open(answers_path) as f:
442+
current_answers = yaml.safe_load(f) or {}
443+
# setdefault: only fill in MISSING flags. Don't overwrite an
444+
# explicit ``False`` from a user who deliberately removed
445+
# a service.
446+
changed = False
447+
for flag, value in detected_flags.items():
448+
if flag not in current_answers:
449+
current_answers[flag] = value
450+
changed = True
451+
if changed:
452+
with open(answers_path, "w") as f:
453+
yaml.safe_dump(
454+
current_answers,
455+
f,
456+
default_flow_style=False,
457+
sort_keys=False,
458+
)
459+
# Copier requires a clean git tree, so commit the
460+
# backfill. If the commit fails (e.g. blocked by a
461+
# pre-commit hook) the working tree is left dirty —
462+
# which would cause copier to fail with a confusing
463+
# error several steps later. Verify the tree is clean
464+
# post-commit and abort with a clear message if not.
465+
try:
466+
subprocess.run(
467+
["git", "add", ".copier-answers.yml"],
468+
cwd=target_path,
469+
check=True,
470+
capture_output=True,
471+
)
472+
subprocess.run(
473+
[
474+
"git",
475+
"commit",
476+
"-m",
477+
"Backfill missing copier flags from project structure",
478+
],
479+
cwd=target_path,
480+
check=True,
481+
capture_output=True,
482+
)
483+
except subprocess.CalledProcessError as exc:
484+
# ``git commit`` exits non-zero when there's
485+
# nothing to commit too — that's harmless. Only
486+
# abort if the answers file is actually dirty.
487+
status = subprocess.run(
488+
["git", "status", "--porcelain", ".copier-answers.yml"],
489+
cwd=target_path,
490+
capture_output=True,
491+
text=True,
492+
)
493+
if status.stdout.strip():
494+
stderr = (
495+
(exc.stderr or b"")
496+
.decode("utf-8", errors="replace")
497+
.strip()
498+
)
499+
typer.secho(
500+
"Failed to commit backfilled .copier-answers.yml; "
501+
"aborting because copier requires a clean git tree.",
502+
fg="red",
503+
err=True,
504+
)
505+
if stderr:
506+
typer.echo(stderr, err=True)
507+
raise typer.Exit(1) from exc
508+
361509
# Run Copier update with git-aware merge
362510
# NOTE: We do NOT pass src_path - Copier reads it from .copier-answers.yml
363511
# This is critical for Copier's git tracking detection to work correctly
@@ -374,6 +522,9 @@ def update_command(
374522
typer.echo(t("update.updating_to", version=target_version_display))
375523

376524
# Load answers for cleanup and post-generation tasks
525+
# (the answers file was already backfilled with detected flags
526+
# above, so it has every ``include_*`` value cleanup_components
527+
# needs to make correct decisions.)
377528
answers = load_copier_answers(target_path)
378529

379530
# Clean up nested directory if Copier created one

aegis/templates/copier-aegis-project/{{ project_slug }}/tests/services/test_component_integration.py.jinja

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ from app.services.system.health import (
2424
from app.services.system.health import check_cache_health
2525
{% endif %}
2626
{%- if include_database %}
27-
from app.services.system.health import check_database_health
27+
from app.services.system.health_db import check_database_health
2828
{% endif %}
2929
{%- if include_worker %}
3030
from app.services.system.health import check_worker_health

copier.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
# - Update support
77

88
_min_copier_version: "9.0.0"
9-
_version: "0.6.11rc1"
9+
_version: "0.6.11rc2"
1010

1111
# IMPORTANT: Template content is in subdirectory
1212
# This allows the template to be recognized as git-tracked (aegis-stack repo root has .git)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "aegis-stack"
3-
version = "0.6.11rc1"
3+
version = "0.6.11rc2"
44
description = "A production-ready FastAPI platform with modular components and a built-in control plane. Try: uvx aegis-stack init my-project"
55
readme = "README.md"
66
requires-python = ">=3.11,<3.15"

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)