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
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
127 changes: 61 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,66 @@ 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.
# Skip settings/defaults — already copied from old version above
if item in ("settings.yaml", "defaults.yaml"):
continue
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 +894,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 +908,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 +941,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