#!/usr/bin/env bash set -euo pipefail project_dir="" project_name="" server_host="root@8.211.173.24" server_root="/root/continue" gitea_base_url="https://git.hk.hao.work" repo_owner="zt" visibility="private" branch="main" sync_interval="15" continuity_mode="handoff-summary" overwrite_existing="backup" gitea_token="${GITEA_TOKEN:-}" gitea_token_file="" install_launchd=1 install_systemd=1 enable_services=1 skill_dir="$(cd "$(dirname "$0")/.." && pwd)" usage() { cat <<'EOF' Usage: run.sh --project-dir [options] Bootstrap continuous sync for a local Codex workspace: - create or reuse a Gitea repo - push the current workspace snapshot - provision /root/continue/ on the server - generate .codex-sync metadata and handoff files - install local launchd and remote systemd timers Options: --project-dir Local workspace to migrate (required) --project-name Repo and server project name (default: basename of project dir) --server-host Remote SSH target (default: root@8.211.173.24) --server-root Remote parent directory (default: /root/continue) --gitea-base-url Gitea base URL (default: https://git.hk.hao.work) --repo-owner Gitea owner/org (default: zt) --visibility private|public|internal (default: private) --branch Git branch to synchronize (default: main) --sync-interval Background sync interval (default: 15) --gitea-token Gitea API token --gitea-token-file Read Gitea API token from file --overwrite-existing backup|replace|abort (default: backup) --no-launchd Skip local launchd installation --no-systemd Skip remote systemd installation --no-enable-services Install services but do not enable/start them -h, --help Show this message EOF } die() { echo "$*" >&2 exit 1 } require_cmd() { command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1" } sanitize_slug() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//; s/-+/-/g' } abs_path() { python3 - "$1" <<'PY' from pathlib import Path import sys print(Path(sys.argv[1]).expanduser().resolve()) PY } append_unique_line() { local file_path="$1" local line="$2" mkdir -p "$(dirname "$file_path")" touch "$file_path" if ! grep -Fxq "$line" "$file_path"; then printf '%s\n' "$line" >>"$file_path" fi } while [[ $# -gt 0 ]]; do case "$1" in --project-dir) project_dir="$2" shift 2 ;; --project-name) project_name="$2" shift 2 ;; --server-host) server_host="$2" shift 2 ;; --server-root) server_root="$2" shift 2 ;; --gitea-base-url) gitea_base_url="$2" shift 2 ;; --repo-owner) repo_owner="$2" shift 2 ;; --visibility) visibility="$2" shift 2 ;; --branch) branch="$2" shift 2 ;; --sync-interval) sync_interval="$2" shift 2 ;; --gitea-token) gitea_token="$2" shift 2 ;; --gitea-token-file) gitea_token_file="$2" shift 2 ;; --overwrite-existing) overwrite_existing="$2" shift 2 ;; --no-launchd) install_launchd=0 shift ;; --no-systemd) install_systemd=0 shift ;; --no-enable-services) enable_services=0 shift ;; -h|--help) usage exit 0 ;; *) die "Unknown argument: $1" ;; esac done [[ -n "$project_dir" ]] || die "--project-dir is required" case "$visibility" in private|public|internal) ;; *) die "--visibility must be private, public, or internal" ;; esac if [[ "$visibility" == "internal" ]]; then echo "Warning: this Gitea instance does not expose an internal repository visibility field in the create/edit API; the repo will be created as private." >&2 fi case "$overwrite_existing" in backup|replace|abort) ;; *) die "--overwrite-existing must be backup, replace, or abort" ;; esac [[ "$sync_interval" =~ ^[0-9]+$ ]] || die "--sync-interval must be an integer number of seconds" if [[ -n "$gitea_token_file" ]]; then gitea_token_file="$(abs_path "$gitea_token_file")" [[ -f "$gitea_token_file" ]] || die "Token file not found: $gitea_token_file" gitea_token="$(<"$gitea_token_file")" fi gitea_token="${gitea_token#"${gitea_token%%[![:space:]]*}"}" gitea_token="${gitea_token%"${gitea_token##*[![:space:]]}"}" [[ -n "$gitea_token" ]] || die "Provide --gitea-token, --gitea-token-file, or GITEA_TOKEN" require_cmd bash require_cmd curl require_cmd git require_cmd jq require_cmd python3 require_cmd rsync require_cmd ssh if [[ "$install_launchd" -eq 1 ]]; then require_cmd launchctl fi project_dir="$(abs_path "$project_dir")" [[ -d "$project_dir" ]] || die "Project directory not found: $project_dir" if [[ -z "$project_name" ]]; then project_name="$(basename "$project_dir")" fi project_slug="$(sanitize_slug "$project_name")" [[ -n "$project_slug" ]] || die "Failed to derive a valid slug from project name: $project_name" remote_dir="${server_root%/}/$project_name" local_launchd_label="com.codex.sync.${project_slug}" local_launchd_plist="$HOME/Library/LaunchAgents/${local_launchd_label}.plist" remote_service_name="codex-sync-${project_slug}.service" remote_timer_name="codex-sync-${project_slug}.timer" ssh_base=( ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new "$server_host" ) for remote_cmd in git python3 bash; do "${ssh_base[@]}" "command -v $remote_cmd >/dev/null 2>&1" || die "Remote host is missing required command: $remote_cmd" done if [[ "$install_systemd" -eq 1 ]]; then "${ssh_base[@]}" "command -v systemctl >/dev/null 2>&1" || die "Remote host is missing required command: systemctl" fi repo_info_json="$(python3 - "$gitea_base_url" "$gitea_token" "$repo_owner" "$project_name" "$visibility" "$branch" <<'PY' import json import sys import urllib.error import urllib.request base_url, token, owner, repo_name, visibility, branch = sys.argv[1:] headers = { "Authorization": f"token {token}", "Accept": "application/json", "Content-Type": "application/json", } def request_json(url, method="GET", payload=None): req = urllib.request.Request(url, method=method, headers=headers, data=payload) with urllib.request.urlopen(req, timeout=60) as response: return json.loads(response.read().decode("utf-8")) user = request_json(f"{base_url}/api/v1/user") login = user["login"] repo_endpoint = f"{base_url}/api/v1/repos/{owner}/{repo_name}" try: repo = request_json(repo_endpoint) except urllib.error.HTTPError as exc: if exc.code != 404: raise payload = { "name": repo_name, "default_branch": branch, "description": f"Auto-synced Codex workspace for {repo_name}", "auto_init": False, "private": visibility != "public", } create_url = ( f"{base_url}/api/v1/user/repos" if owner == login else f"{base_url}/api/v1/orgs/{owner}/repos" ) repo = request_json(create_url, method="POST", payload=json.dumps(payload).encode("utf-8")) print(json.dumps({"login": login, "repo": repo})) PY )" repo_login="$(printf '%s' "$repo_info_json" | jq -r '.login')" repo_ssh_url="$(printf '%s' "$repo_info_json" | jq -r '.repo.ssh_url')" repo_http_url="$(printf '%s' "$repo_info_json" | jq -r '.repo.html_url')" repo_clone_url="$(printf '%s' "$repo_info_json" | jq -r '.repo.clone_url')" transport_url="$repo_ssh_url" transport_mode="ssh" check_remote_access() { local url="$1" local mode="$2" if [[ "$mode" == "local" ]]; then git ls-remote "$url" >/dev/null 2>&1 else "${ssh_base[@]}" "git ls-remote '$url' >/dev/null 2>&1" fi } if ! check_remote_access "$repo_ssh_url" "local" || ! check_remote_access "$repo_ssh_url" "remote"; then transport_url="$(python3 - "$repo_clone_url" "$repo_login" "$gitea_token" <<'PY' from urllib.parse import urlsplit, urlunsplit, quote import sys clone_url, username, token = sys.argv[1:] parts = urlsplit(clone_url) netloc = f"{quote(username, safe='')}:{quote(token, safe='')}@{parts.hostname}" if parts.port: netloc += f":{parts.port}" print(urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment))) PY )" transport_mode="https-token" if ! check_remote_access "$transport_url" "local"; then die "Neither SSH nor HTTPS token transport can access the repo from the local host" fi fi if [[ ! -d "$project_dir/.git" ]]; then git -C "$project_dir" init -b "$branch" >/dev/null fi git -C "$project_dir" checkout -B "$branch" >/dev/null if [[ -z "$(git -C "$project_dir" config user.name || true)" ]]; then git -C "$project_dir" config user.name "Codex Sync" fi if [[ -z "$(git -C "$project_dir" config user.email || true)" ]]; then git -C "$project_dir" config user.email "codex-sync@local" fi mkdir -p "$project_dir/.codex-sync/bin" mkdir -p "$project_dir/.codex-sync/runtime" cp "$skill_dir/scripts/sync_once.sh" "$project_dir/.codex-sync/bin/sync-once.sh" cp "$skill_dir/scripts/continue_task.sh" "$project_dir/.codex-sync/bin/continue-task.sh" chmod +x "$project_dir/.codex-sync/bin/sync-once.sh" "$project_dir/.codex-sync/bin/continue-task.sh" append_unique_line "$project_dir/.gitignore" ".codex-sync/runtime/" python3 - "$project_dir/.codex-sync/manifest.json" "$project_name" "$project_slug" "$branch" "$repo_owner" "$repo_http_url" "$repo_ssh_url" "$transport_mode" "$server_host" "$remote_dir" "$sync_interval" "$continuity_mode" "$local_launchd_label" "$local_launchd_plist" "$remote_service_name" "$remote_timer_name" <<'PY' import json import sys from datetime import datetime, timezone from pathlib import Path ( manifest_path, project_name, project_slug, branch, repo_owner, repo_http_url, repo_ssh_url, transport_mode, server_host, remote_dir, sync_interval, continuity_mode, local_launchd_label, local_launchd_plist, remote_service_name, remote_timer_name, ) = sys.argv[1:] manifest = { "version": 1, "generatedAt": datetime.now(timezone.utc).isoformat(), "projectName": project_name, "projectSlug": project_slug, "branch": branch, "repoOwner": repo_owner, "repoHttpUrl": repo_http_url, "repoSshUrl": repo_ssh_url, "remoteTransport": transport_mode, "serverHost": server_host, "serverPath": remote_dir, "syncIntervalSeconds": int(sync_interval), "continuityMode": continuity_mode, "conflictPolicy": "stop-and-surface-status", "gitIdentity": { "name": "Codex Sync", "email": "codex-sync@local", }, "services": { "launchdLabel": local_launchd_label, "launchdPlist": local_launchd_plist, "systemdService": remote_service_name, "systemdTimer": remote_timer_name, }, } path = Path(manifest_path) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") PY python3 "$skill_dir/scripts/generate_handoff.py" \ --project-dir "$project_dir" \ --output "$project_dir/.codex-sync/handoff.md" cat >"$project_dir/.codex-sync/README.md" </dev/null || true)" ]]; then git -C "$project_dir" add -A git -C "$project_dir" commit -m "Bootstrap Codex task sync" >/dev/null fi if git -C "$project_dir" remote get-url origin >/dev/null 2>&1; then git -C "$project_dir" remote set-url origin "$transport_url" else git -C "$project_dir" remote add origin "$transport_url" fi git -C "$project_dir" push -u origin "$branch" >/dev/null 2>&1 || "$project_dir/.codex-sync/bin/sync-once.sh" --project-dir "$project_dir" --host-label "bootstrap-local" "${ssh_base[@]}" bash -s -- "$remote_dir" "$branch" "$transport_url" "$repo_ssh_url" "$repo_clone_url" "$overwrite_existing" <<'EOF_REMOTE_REPO' set -euo pipefail remote_dir="$1" branch="$2" transport_url="$3" repo_ssh_url="$4" repo_clone_url="$5" overwrite_existing="$6" normalize_remote() { python3 - "$1" <<'PY' import re import sys from urllib.parse import urlsplit, urlunsplit raw = sys.argv[1] if raw.startswith("ssh://"): parts = urlsplit(raw) print(f"{parts.hostname}:{parts.path.lstrip('/')}") elif re.match(r"^[^@]+@[^:]+:.+$", raw): print(raw.split("@", 1)[1]) else: parts = urlsplit(raw) host = parts.hostname or "" print(f"{host}:{parts.path.lstrip('/')}") PY } expected_ssh="$(normalize_remote "$repo_ssh_url")" expected_http="$(normalize_remote "$repo_clone_url")" if [[ -d "$remote_dir" ]]; then current_remote="" if [[ -d "$remote_dir/.git" ]]; then current_remote="$(git -C "$remote_dir" config --get remote.origin.url || true)" fi if [[ -n "$current_remote" ]]; then current_norm="$(normalize_remote "$current_remote")" else current_norm="" fi if [[ "$current_norm" != "$expected_ssh" && "$current_norm" != "$expected_http" ]]; then case "$overwrite_existing" in backup) mv "$remote_dir" "${remote_dir}.bak.$(date +%Y%m%d%H%M%S)" ;; replace) rm -rf "$remote_dir" ;; abort) echo "Remote path exists and is unrelated: $remote_dir" >&2 exit 1 ;; esac fi fi mkdir -p "$(dirname "$remote_dir")" if [[ ! -d "$remote_dir/.git" ]]; then git clone "$transport_url" "$remote_dir" >/dev/null 2>&1 fi if [[ -n "$(git -C "$remote_dir" status --porcelain || true)" ]]; then echo "Remote checkout is dirty and cannot be updated safely: $remote_dir" >&2 exit 1 fi git -C "$remote_dir" fetch origin "$branch" >/dev/null 2>&1 || true if git -C "$remote_dir" show-ref --verify --quiet "refs/remotes/origin/$branch"; then git -C "$remote_dir" checkout -B "$branch" "origin/$branch" >/dev/null 2>&1 else git -C "$remote_dir" checkout -B "$branch" >/dev/null 2>&1 fi if [[ -z "$(git -C "$remote_dir" config user.name || true)" ]]; then git -C "$remote_dir" config user.name "Codex Sync" fi if [[ -z "$(git -C "$remote_dir" config user.email || true)" ]]; then git -C "$remote_dir" config user.email "codex-sync@remote" fi EOF_REMOTE_REPO if [[ "$install_launchd" -eq 1 ]]; then mkdir -p "$(dirname "$local_launchd_plist")" cat >"$local_launchd_plist" < Label ${local_launchd_label} ProgramArguments /bin/bash ${project_dir}/.codex-sync/bin/sync-once.sh --project-dir ${project_dir} --host-label local-$(hostname -s) WorkingDirectory ${project_dir} StartInterval ${sync_interval} RunAtLoad StandardOutPath ${project_dir}/.codex-sync/runtime/launchd.out.log StandardErrorPath ${project_dir}/.codex-sync/runtime/launchd.err.log EOF if [[ "$enable_services" -eq 1 ]]; then launchctl bootout "gui/$(id -u)" "$local_launchd_plist" >/dev/null 2>&1 || true if ! launchctl bootstrap "gui/$(id -u)" "$local_launchd_plist" >/dev/null 2>&1; then launchctl load -w "$local_launchd_plist" >/dev/null 2>&1 fi launchctl kickstart -k "gui/$(id -u)/${local_launchd_label}" >/dev/null 2>&1 || true fi fi if [[ "$install_systemd" -eq 1 ]]; then "${ssh_base[@]}" bash -s -- "$remote_service_name" "$remote_timer_name" "$remote_dir" "$sync_interval" "$enable_services" <<'EOF_REMOTE_SYSTEMD' set -euo pipefail service_name="$1" timer_name="$2" remote_dir="$3" sync_interval="$4" enable_services="$5" service_path="/etc/systemd/system/$service_name" timer_path="/etc/systemd/system/$timer_name" cat >"$service_path" <"$timer_path" </dev/null 2>&1 systemctl start "$service_name" >/dev/null 2>&1 || true fi EOF_REMOTE_SYSTEMD fi "$project_dir/.codex-sync/bin/sync-once.sh" --project-dir "$project_dir" --host-label "bootstrap-local" "${ssh_base[@]}" "cd '$remote_dir' && ./.codex-sync/bin/sync-once.sh --project-dir '$remote_dir' --host-label 'bootstrap-remote'" || true cat <