from __future__ import annotations
import hashlib
import logging
import os
import platform
import stat
import sys
from pathlib import Path
import yaml
logging.basicConfig(
format='{"time": "%(asctime)s", "level": "%(levelname)s", "logger": "%(name)s", "msg": %(message)s}',
level=logging.INFO,
stream=sys.stdout,
)
log = logging.getLogger("synqro.post_install")
CONFIG_FILENAME = "synqro_ota.yaml"
GITIGNORE_FILENAME = ".gitignore"
CACHE_DIR = ".synqro_cache"
KEYSTORE_DIR = ".synqro_keystore"
STAGING_DIR = ".synqro_cache/staging"
BACKUP_DIR = ".synqro_cache/backup"
GITIGNORE_BLOCK = """\
# Synqro — auto-added by post-install
synqro_ota.yaml
.synqro_cache/
.synqro_keystore/
"""
CONFIG_HEADER = (
"# Synqro Configuration \u2014 DO NOT COMMIT THIS FILE\n"
"# Auto-generated by Synqro post-install. See docs for field descriptions.\n"
)
def _generate_installation_id() -> str:
raw = os.urandom(32)
return hashlib.sha256(raw).hexdigest()
def _load_existing_config(config_path: Path) -> dict | None:
if not config_path.exists():
return None
try:
text = config_path.read_text(encoding="utf-8")
return yaml.safe_load(text)
except (OSError, yaml.YAMLError) as exc:
log.warning('"Failed to read existing config: %s"', exc)
return None
def _is_valid_installation_id(cfg: dict | None) -> bool:
if not isinstance(cfg, dict):
return False
try:
inst_id = cfg["synqro"]["installation_id"]
except (KeyError, TypeError):
return False
return bool(inst_id) and inst_id != "REPLACE_ME"
def _build_config(installation_id: str) -> dict:
return {
"synqro": {
"version": "1.0",
"installation_id": installation_id,
"source": {
"provider": "github",
"owner": "REPLACE_ME",
"repo": "REPLACE_ME",
"branch": "main",
"manifest_path": "synqro_manifest.json",
},
"auth": {
"token_source": "keychain",
"env_var": "",
},
"crypto": {
"release_signing_pubkey": "REPLACE_ME",
"github_api_cert_pin": "REPLACE_ME",
},
"update": {
"check_interval_seconds": 3600,
"max_retries": 3,
"retry_backoff_base_seconds": 5,
"download_timeout_seconds": 60,
"max_payload_size_bytes": 52428800,
"staging_dir": ".synqro_cache/staging",
"backup_dir": ".synqro_cache/backup",
},
"rollback": {
"enabled": True,
"health_check_timeout_seconds": 30,
"max_backup_versions": 2,
},
"reporting": {
"enabled": True,
"telegram_token_source": "keychain",
"telegram_token_env_var": "",
"telegram_chat_id": "REPLACE_ME",
"scrub_patterns": [
r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b",
r"\b(?:\d{1,3}\.){3}\d{1,3}\b",
r"ghp_[A-Za-z0-9]{36}",
r"(?i)(password|secret|token|key)\s*[=:>]\s*\S+",
],
},
"logging": {
"level": "warn",
"structured": True,
"audit_log_path": ".synqro_cache/audit.log.jsonl",
"audit_hmac_enabled": True,
},
}
}
def _write_config_atomic(config_path: Path, content: str) -> None:
flags = os.O_CREAT | os.O_WRONLY | os.O_EXCL
try:
fd = os.open(str(config_path), flags, 0o600)
except FileExistsError:
log.warning('"Config file appeared unexpectedly during atomic write — aborting write."')
raise
try:
os.write(fd, content.encode("utf-8"))
finally:
os.close(fd)
os.chmod(str(config_path), stat.S_IRUSR | stat.S_IWUSR)
def _update_gitignore(base_dir: Path) -> None:
gitignore_path = base_dir / GITIGNORE_FILENAME
git_dir = base_dir / ".git"
if not git_dir.is_dir():
sys.stdout.write(
"WARNING: No .git/ directory found in the project root. "
".gitignore was updated, but this does not appear to be a Git repository.\n"
)
existing = ""
if gitignore_path.exists():
try:
existing = gitignore_path.read_text(encoding="utf-8")
except OSError as exc:
log.warning('"Could not read .gitignore: %s"', exc)
if "synqro_ota.yaml" in existing and ".synqro_cache/" in existing:
log.info('"Synqro entries already present in .gitignore — skipping."')
return
try:
with gitignore_path.open("a", encoding="utf-8") as fh:
if existing and not existing.endswith("\n"):
fh.write("\n")
fh.write(GITIGNORE_BLOCK)
log.info('"Updated .gitignore with Synqro entries."')
except OSError as exc:
log.error('"Failed to update .gitignore: %s"', exc)
sys.exit(1)
def _create_dir_0700(path: Path) -> None:
try:
path.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(str(path), stat.S_IRWXU)
log.info('"Created directory: %s"', path)
except OSError as exc:
log.error('"Failed to create directory %s: %s"', path, exc)
sys.exit(1)
def _print_keychain_instructions() -> None:
system = platform.system()
sys.stdout.write("\n" + "=" * 70 + "\n")
sys.stdout.write("KEYCHAIN SETUP — run these commands to store your secrets securely\n")
sys.stdout.write("=" * 70 + "\n\n")
if system == "Linux":
sys.stdout.write("Platform: Linux (libsecret / secret-tool)\n\n")
sys.stdout.write(
" # Store GitHub Personal Access Token:\n"
" secret-tool store --label='Synqro GitHub Token' \\\n"
" service synqro account github_token\n\n"
" # Store Telegram Bot Token:\n"
" secret-tool store --label='Synqro Telegram Token' \\\n"
" service synqro account telegram_token\n\n"
" # Verify storage:\n"
" secret-tool lookup service synqro account github_token\n"
" secret-tool lookup service synqro account telegram_token\n\n"
" NOTE: Install libsecret if missing: sudo apt install libsecret-tools\n"
)
elif system == "Darwin":
sys.stdout.write("Platform: macOS (Keychain Access / security)\n\n")
sys.stdout.write(
" # Store GitHub Personal Access Token:\n"
" security add-generic-password -s synqro -a github_token -w\n\n"
" # Store Telegram Bot Token:\n"
" security add-generic-password -s synqro -a telegram_token -w\n\n"
" # Verify storage:\n"
" security find-generic-password -s synqro -a github_token -w\n"
" security find-generic-password -s synqro -a telegram_token -w\n"
)
elif system == "Windows":
sys.stdout.write("Platform: Windows (Credential Manager)\n\n")
sys.stdout.write(
" # Install the 'keyring' Python package, then run:\n"
" python -c \"import keyring; keyring.set_password('synqro', 'github_token', input('Token: '))\"\n"
" python -c \"import keyring; keyring.set_password('synqro', 'telegram_token', input('Token: '))\"\n\n"
" # Alternatively via PowerShell (no shell=True used in Synqro):\n"
" # cmdkey /generic:synqro_github_token /user:synqro /pass\n"
)
else:
sys.stdout.write(
"Platform: Unknown — please store secrets in your OS keychain manually.\n"
" Environment variables are also supported:\n"
" SYNQRO_GITHUB_TOKEN=<token> python -m synqro ...\n"
" SYNQRO_TELEGRAM_TOKEN=<token> python -m synqro ...\n"
)
sys.stdout.write("\n")
def _print_setup_checklist() -> None:
sys.stdout.write("\n" + "=" * 70 + "\n")
sys.stdout.write("SYNQRO SETUP CHECKLIST\n")
sys.stdout.write("=" * 70 + "\n\n")
sys.stdout.write(
" Step 1: Generate an Ed25519 signing keypair\n"
" openssl genpkey -algorithm ed25519 -out synqro_signing.key\n"
" openssl pkey -in synqro_signing.key -pubout -out synqro_signing.pub\n"
" # Paste the public key content into synqro_ota.yaml -> crypto.release_signing_pubkey\n\n"
" Step 2: Store your GitHub PAT in the OS keychain\n"
" (See keychain instructions above)\n\n"
" Step 3: Store your Telegram bot token in the OS keychain\n"
" (See keychain instructions above)\n\n"
" Step 4: Fill in all remaining REPLACE_ME fields in synqro_ota.yaml:\n"
" - source.owner (your GitHub username/org)\n"
" - source.repo (your GitHub repo name)\n"
" - crypto.github_api_cert_pin (SHA-256 of GitHub API TLS cert)\n"
" - reporting.telegram_chat_id (your Telegram chat ID)\n\n"
" Step 5: Verify your configuration:\n"
" synqro verify\n\n"
" IMPORTANT: Never commit synqro_ota.yaml — it contains your installation_id.\n"
" The .gitignore has been updated to exclude it automatically.\n"
)
sys.stdout.write("=" * 70 + "\n\n")
def main() -> int:
scripts_dir = Path(__file__).resolve().parent
base_dir = scripts_dir.parent
config_path = base_dir / CONFIG_FILENAME
log.info('"Synqro post-install starting. Base dir: %s"', base_dir)
existing_cfg = _load_existing_config(config_path)
if _is_valid_installation_id(existing_cfg):
sys.stdout.write("Config already exists, skipping.\n")
log.info('"Valid synqro_ota.yaml already present — nothing to do."')
return 0
installation_id = _generate_installation_id()
log.info('"Generated new installation_id (first 8 chars): %s..."', installation_id[:8])
config_data = _build_config(installation_id)
yaml_body = yaml.dump(
config_data,
default_flow_style=False,
allow_unicode=True,
sort_keys=False,
width=120,
)
full_content = CONFIG_HEADER + yaml_body
try:
_write_config_atomic(config_path, full_content)
log.info('"Wrote synqro_ota.yaml with mode 0o600."')
except FileExistsError:
sys.stdout.write("Config already exists, skipping.\n")
return 0
except OSError as exc:
log.error('"Failed to write synqro_ota.yaml: %s"', exc)
return 1
for rel_dir in (CACHE_DIR, KEYSTORE_DIR, STAGING_DIR, BACKUP_DIR):
_create_dir_0700(base_dir / rel_dir)
_update_gitignore(base_dir)
_print_keychain_instructions()
_print_setup_checklist()
sys.stdout.write(
"Post-install complete.\n"
"synqro_ota.yaml written to: {}\n".format(config_path)
)
return 0
if __name__ == "__main__":
sys.exit(main())