Add codex-task-server-sync skill
这个提交包含在:
635
codex-task-server-sync/scripts/run.sh
可执行文件
635
codex-task-server-sync/scripts/run.sh
可执行文件
@@ -0,0 +1,635 @@
|
||||
#!/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
|
||||
在新工单中引用
屏蔽一个用户