636 行
18 KiB
Bash
可执行文件
636 行
18 KiB
Bash
可执行文件
#!/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 <path> [options]
|
|
|
|
Bootstrap continuous sync for a local Codex workspace:
|
|
- create or reuse a Gitea repo
|
|
- push the current workspace snapshot
|
|
- provision /root/continue/<project> on the server
|
|
- generate .codex-sync metadata and handoff files
|
|
- install local launchd and remote systemd timers
|
|
|
|
Options:
|
|
--project-dir <path> Local workspace to migrate (required)
|
|
--project-name <name> Repo and server project name (default: basename of project dir)
|
|
--server-host <user@host> Remote SSH target (default: root@8.211.173.24)
|
|
--server-root <path> Remote parent directory (default: /root/continue)
|
|
--gitea-base-url <url> Gitea base URL (default: https://git.hk.hao.work)
|
|
--repo-owner <name> Gitea owner/org (default: zt)
|
|
--visibility <value> private|public|internal (default: private)
|
|
--branch <name> Git branch to synchronize (default: main)
|
|
--sync-interval <seconds> Background sync interval (default: 15)
|
|
--gitea-token <token> Gitea API token
|
|
--gitea-token-file <path> Read Gitea API token from file
|
|
--overwrite-existing <mode> 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" <<EOF
|
|
# Codex Sync
|
|
|
|
This workspace is synchronized through Gitea and mirrored to \`${remote_dir}\` on \`${server_host}\`.
|
|
|
|
## Inspect Status
|
|
|
|
\`\`\`bash
|
|
cat .codex-sync/manifest.json
|
|
cat .codex-sync/runtime/status.json
|
|
cat .codex-sync/runtime/last-error.log
|
|
git status -sb
|
|
\`\`\`
|
|
|
|
## Local launchd
|
|
|
|
- Label: \`${local_launchd_label}\`
|
|
- Plist: \`${local_launchd_plist}\`
|
|
|
|
\`\`\`bash
|
|
launchctl print "gui/\$(id -u)/${local_launchd_label}"
|
|
launchctl kickstart -k "gui/\$(id -u)/${local_launchd_label}"
|
|
\`\`\`
|
|
|
|
## Remote systemd
|
|
|
|
- Service: \`${remote_service_name}\`
|
|
- Timer: \`${remote_timer_name}\`
|
|
|
|
\`\`\`bash
|
|
ssh ${server_host} 'systemctl status ${remote_service_name} ${remote_timer_name}'
|
|
ssh ${server_host} 'systemctl restart ${remote_service_name}'
|
|
ssh ${server_host} 'systemctl restart ${remote_timer_name}'
|
|
\`\`\`
|
|
|
|
## Continue On The Server
|
|
|
|
\`\`\`bash
|
|
ssh ${server_host} 'cd ${remote_dir} && ./.codex-sync/bin/continue-task.sh'
|
|
\`\`\`
|
|
|
|
If sync stops after a conflict, resolve the git state, remove \`.codex-sync/runtime/blocked\`, and run \`./.codex-sync/bin/sync-once.sh\` again.
|
|
EOF
|
|
|
|
git -C "$project_dir" add .gitignore .codex-sync
|
|
if ! git -C "$project_dir" diff --cached --quiet || [[ -z "$(git -C "$project_dir" rev-parse --verify HEAD 2>/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" <<EOF
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>Label</key>
|
|
<string>${local_launchd_label}</string>
|
|
<key>ProgramArguments</key>
|
|
<array>
|
|
<string>/bin/bash</string>
|
|
<string>${project_dir}/.codex-sync/bin/sync-once.sh</string>
|
|
<string>--project-dir</string>
|
|
<string>${project_dir}</string>
|
|
<string>--host-label</string>
|
|
<string>local-$(hostname -s)</string>
|
|
</array>
|
|
<key>WorkingDirectory</key>
|
|
<string>${project_dir}</string>
|
|
<key>StartInterval</key>
|
|
<integer>${sync_interval}</integer>
|
|
<key>RunAtLoad</key>
|
|
<true/>
|
|
<key>StandardOutPath</key>
|
|
<string>${project_dir}/.codex-sync/runtime/launchd.out.log</string>
|
|
<key>StandardErrorPath</key>
|
|
<string>${project_dir}/.codex-sync/runtime/launchd.err.log</string>
|
|
</dict>
|
|
</plist>
|
|
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" <<EOF_SERVICE
|
|
[Unit]
|
|
Description=Codex task sync for $(basename "$remote_dir")
|
|
After=network-online.target
|
|
Wants=network-online.target
|
|
|
|
[Service]
|
|
Type=oneshot
|
|
WorkingDirectory=$remote_dir
|
|
ExecStart=/usr/bin/bash -lc 'cd "$remote_dir" && ./.codex-sync/bin/sync-once.sh --project-dir "$remote_dir" --host-label "remote-$(hostname -s)"'
|
|
EOF_SERVICE
|
|
|
|
cat >"$timer_path" <<EOF_TIMER
|
|
[Unit]
|
|
Description=Run $service_name every ${sync_interval}s
|
|
|
|
[Timer]
|
|
OnBootSec=15sec
|
|
OnUnitActiveSec=${sync_interval}s
|
|
Persistent=true
|
|
Unit=$service_name
|
|
|
|
[Install]
|
|
WantedBy=timers.target
|
|
EOF_TIMER
|
|
|
|
systemctl daemon-reload
|
|
if [[ "$enable_services" -eq 1 ]]; then
|
|
systemctl enable --now "$timer_name" >/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 <<EOF
|
|
Bootstrap complete.
|
|
|
|
Project: $project_name
|
|
Project root: $project_dir
|
|
Repo owner: $repo_owner
|
|
Repo URL: $repo_http_url
|
|
Transport: $transport_mode
|
|
Remote path: $remote_dir
|
|
Branch: $branch
|
|
Launchd: $local_launchd_label
|
|
Systemd: $remote_service_name / $remote_timer_name
|
|
EOF
|