Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
2 changes: 0 additions & 2 deletions build.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,6 @@
"--add-data",
f"templates/configs{os.pathsep}templates/configs", # Config templates only
"--add-data",
f"templates/migration{os.pathsep}templates/migration", # Migration templates
"--add-data",
f"audio_samples{os.pathsep}audio_samples",
"--add-data",
f"LICENSE{os.pathsep}.",
Expand Down
4 changes: 0 additions & 4 deletions build_macos.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,6 @@
["templates/configs", "templates/configs"]
), # Config templates only
"--add-data",
os.pathsep.join(
["templates/migration", "templates/migration"]
), # Migration templates
"--add-data",
os.pathsep.join(["audio_samples", "audio_samples"]),
"--add-data",
os.pathsep.join(["LICENSE", "."]),
Expand Down
23 changes: 14 additions & 9 deletions services/config_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,10 +202,9 @@ def create_config(self, config_name: str, template: Optional[ConfigDirInfo] = No
):
for filename in files:
if filename.endswith("template.yaml"):
shutil.copyfile(
path.join(root, filename),
path.join(new_dir, filename.replace(".template", "")),
)
target = path.join(new_dir, filename.replace(".template", ""))
shutil.copyfile(path.join(root, filename), target)
self._stamp_created_with_version(target)
return ConfigDirInfo(
name=config_name,
directory=config_name,
Expand Down Expand Up @@ -300,6 +299,8 @@ def copy_templates(self, force: bool = False):

if force or (not already_exists and not logical_deleted):
shutil.copyfile(path.join(root, filename), new_filepath)
if filename.endswith("template.yaml"):
self._stamp_created_with_version(new_filepath)
self.printr.print(
f"Created config {new_filepath} from template.",
color=LogType.INFO,
Expand Down Expand Up @@ -1025,6 +1026,14 @@ def get_wingman_avatar_path(
avatar_path if create or path.exists(avatar_path) else default_avatar_path
)

def _stamp_created_with_version(self, yaml_path: str) -> None:
"""Stamp a wingman config file with the current Core version."""
with open(yaml_path, "r", encoding="UTF-8") as f:
data = yaml.safe_load(f) or {}
data["created_with_version"] = LOCAL_VERSION
with open(yaml_path, "w", encoding="UTF-8") as f:
yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True)

def restore_wingman_from_template(
self, config_dir: ConfigDirInfo, wingman_file: WingmanConfigFileInfo
) -> None:
Expand Down Expand Up @@ -1057,11 +1066,7 @@ def restore_wingman_from_template(
shutil.copyfile(template_yaml_path, target_yaml_path)

# Stamp with the current Core version so migrations treat it as fresh.
with open(target_yaml_path, "r", encoding="UTF-8") as f:
data = yaml.safe_load(f) or {}
data["created_with_version"] = LOCAL_VERSION
with open(target_yaml_path, "w", encoding="UTF-8") as f:
yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True)
self._stamp_created_with_version(target_yaml_path)

self.printr.print(
f"Restored Wingman '{wingman_file.name}' in '{config_dir.name}' from template.",
Expand Down
124 changes: 58 additions & 66 deletions services/config_migration_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -780,9 +780,9 @@ def migrate(
self,
old_version: str,
new_version: str,
migrate_settings: Callable[[dict, dict], dict],
migrate_defaults: Callable[[dict, dict], dict],
migrate_wingman: Callable[[dict, Optional[dict]], dict],
migrate_settings: Callable[[dict], dict],
migrate_defaults: Callable[[dict], dict],
migrate_wingman: Callable[[dict], dict],
migrate_secrets: Optional[Callable[[dict], dict]] = None,
migrate_mcp: Optional[Callable[[dict, dict], dict]] = None,
) -> None:
Expand All @@ -791,64 +791,63 @@ def migrate(
new_config_path = path.join(users_dir, new_version, CONFIGS_DIR)

if not path.exists(path.join(users_dir, new_version)):
migration_template_path = path.join(
self.templates_dir, "migration", new_version
)
if path.exists(migration_template_path):
# Get list of config directories from old version (normalized names)
# Include ALL configs regardless of their state (default, undefaulted, or deleted)
# because if ANY version exists, we should skip the template
old_config_normalized = set()
if path.exists(old_config_path):
for item in os.listdir(old_config_path):
item_path = path.join(old_config_path, item)
if path.isdir(item_path) and not item.startswith("."):
# Add ALL configs (including deleted ones) after normalizing
normalized = self.normalize_config_name(item)
old_config_normalized.add(normalized)
self.log(
f"Old config found: {item} (normalized: {normalized})"
)

# Copy migration template but skip configs that exist in old version (in any state)
template_config_path = path.join(migration_template_path, CONFIGS_DIR)
new_version_path = path.join(users_dir, new_version)

# First, copy the entire template structure
shutil.copytree(migration_template_path, new_version_path)
self.log(
f"{new_version} configs not found during multi-step migration. Copied migration templates from {migration_template_path}."
)
os.makedirs(new_config_path, exist_ok=True)

# Build set of normalized old config names for deduplication
old_config_normalized = set()
if path.exists(old_config_path):
for item in os.listdir(old_config_path):
item_path = path.join(old_config_path, item)
if path.isdir(item_path) and not item.startswith("."):
normalized = self.normalize_config_name(item)
old_config_normalized.add(normalized)
self.log(
f"Old config found: {item} (normalized: {normalized})"
)

# Now remove template configs that have any version in old configs
# (whether default, undefaulted, or deleted)
if path.exists(template_config_path):
for item in os.listdir(template_config_path):
item_path = path.join(template_config_path, item)
new_item_path = path.join(new_version_path, CONFIGS_DIR, item)
if path.isdir(item_path) and not item.startswith("."):
normalized = self.normalize_config_name(item)
if normalized in old_config_normalized:
# This template config exists in old version (in some form)
# Remove template to avoid duplicates - old version will be migrated
if path.exists(new_item_path):
shutil.rmtree(new_item_path)
self.log_highlight(
f"Skipped template config '{item}' - config exists in old version (normalized: {normalized})"
)
# Also remove associated avatar if it exists
avatar_path = path.join(
new_version_path,
CONFIGS_DIR,
item.replace(".yaml", ".png"),
# Copy settings.yaml and defaults.yaml from old version
# (they'll be transformed by migration callbacks)
for config_file in ("settings.yaml", "defaults.yaml"):
old_file = path.join(old_config_path, config_file)
new_file = path.join(new_config_path, config_file)
if path.exists(old_file):
shutil.copyfile(old_file, new_file)
self.log(f"Copied {config_file} from old version")

# Copy wingman template configs and assets from current templates
# (templates/configs/) — only for wingmen the user doesn't already have
current_templates_path = path.join(self.templates_dir, CONFIGS_DIR)
if path.exists(current_templates_path):
for item in os.listdir(current_templates_path):
src_path = path.join(current_templates_path, item)
dst_path = path.join(new_config_path, item)
if item.startswith("."):
continue
if path.isdir(src_path):
# Wingman config directory — skip if user already has it
normalized = self.normalize_config_name(item)
if normalized in old_config_normalized:
self.log_highlight(
f"Skipped template config '{item}' - config exists in old version (normalized: {normalized})"
)
continue
shutil.copytree(src_path, dst_path)
# Stamp created_with_version on copied wingman configs
for yaml_file in os.listdir(dst_path):
if yaml_file.endswith(".yaml"):
self.config_manager._stamp_created_with_version(
path.join(dst_path, yaml_file)
)
if path.exists(avatar_path):
os.remove(avatar_path)
else:
self.err(f"Migration template not found: {migration_template_path}")
raise FileNotFoundError(
f"Migration template not found: {migration_template_path}"
)
self.log(f"Copied new wingman template '{item}' from current templates")
elif not path.isdir(src_path):
# Non-directory files (mcp.template.yaml, default-wingman-avatar.png, etc.)
Comment thread
Shackless marked this conversation as resolved.
shutil.copyfile(src_path, dst_path)
self.log(f"Copied '{item}' from current templates")

self.log(
f"{new_version} configs not found during multi-step migration. "
f"Built from old version configs + current templates."
)

already_migrated = path.exists(path.join(new_config_path, MIGRATION_LOG))
if already_migrated:
Expand Down Expand Up @@ -892,7 +891,6 @@ def migrate(
self.log_highlight("Migrating settings.yaml...")
migrated_settings = migrate_settings(
old=self.config_manager.read_config(old_file),
new=self.config_manager.read_config(new_file),
)
try:
if new_config_path == self.latest_config_path:
Expand All @@ -907,7 +905,6 @@ def migrate(
self.log_highlight("Migrating defaults.yaml...")
migrated_defaults = migrate_defaults(
old=self.config_manager.read_config(old_file),
new=self.config_manager.read_config(new_file),
)
try:
# Only validate on final migration step (current schema may not match intermediate versions)
Expand Down Expand Up @@ -941,11 +938,6 @@ def migrate(
default_config = self.config_manager.read_default_config()
migrated_wingman = migrate_wingman(
old=self.config_manager.read_config(old_file),
new=(
self.config_manager.read_config(new_file)
if path.exists(new_file)
else None
),
)
# validate the merged config
if new_config_path == self.latest_config_path:
Expand Down
Loading
Loading