#!/usr/bin/env python3 """Merge instance-specific n8n credential IDs into versioned workflow definitions. n8n's CLI does not reliably resolve two credentials of the same type by name during an update. This utility copies only credential reference IDs/names from a pre-update export; it never reads or writes credential values. """ from __future__ import annotations import argparse import copy import json from pathlib import Path from typing import Any SERVICE_NODE_CANDIDATES = { "mobilityops-return-processing": ("Record follow-up",), "mobilityops-scheduled-quality-scan": ("Run quality scan",), "6wbkc4d1AouGpmWT": ("Report sync result to Fleet Ops",), "Xppn2rAEqUuyiCJF": ("Report failure to Fleet Ops", "failure to Fleet Ops"), } def _workflow_list(path: Path) -> list[dict[str, Any]]: payload = json.loads(path.read_text(encoding="utf-8")) return payload if isinstance(payload, list) else [payload] def merge(backup_path: Path, source_dir: Path, output_dir: Path) -> list[Path]: backups = {workflow["id"]: workflow for workflow in _workflow_list(backup_path)} output_dir.mkdir(parents=True, exist_ok=True) written: list[Path] = [] for source_path in sorted(source_dir.glob("fleet-ops-*.json")): updated = _workflow_list(source_path)[0] workflow_id = updated["id"] if workflow_id not in SERVICE_NODE_CANDIDATES or workflow_id not in backups: continue original_nodes = {node["name"]: node for node in backups[workflow_id]["nodes"]} service_node = next( ( original_nodes[name] for name in SERVICE_NODE_CANDIDATES[workflow_id] if name in original_nodes ), None, ) if service_node is None or not service_node.get("credentials"): raise ValueError(f"No service credential reference found for {workflow_id}") service_credentials = service_node["credentials"] for node in updated["nodes"]: original = original_nodes.get(node["name"]) if original and original.get("credentials"): node["credentials"] = copy.deepcopy(original["credentials"]) elif node.get("credentials") or node["name"] == "Report workflow heartbeat": node["credentials"] = copy.deepcopy(service_credentials) target = output_dir / source_path.name target.write_text( json.dumps(updated, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" ) written.append(target) return written def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("backup", type=Path, help="n8n export:workflow --all JSON file") parser.add_argument("source_dir", type=Path, help="versioned workflow directory") parser.add_argument("output_dir", type=Path, help="safe import output directory") args = parser.parse_args() for path in merge(args.backup, args.source_dir, args.output_dir): print(path) if __name__ == "__main__": main()