Add codex-task-server-sync skill

这个提交包含在:
Codex
2026-03-16 23:46:45 -07:00
父节点 b2c5ef588d
当前提交 fc8ad7c145
修改 8 个文件,包含 1257 行新增0 行删除

查看文件

@@ -0,0 +1,49 @@
---
name: codex-task-server-sync
description: Migrate a macOS Codex workspace or running task to a Linux server, create or reuse a Gitea repo, sync it into /root/continue/project-name, and keep local and remote changes synchronized for uninterrupted continuation.
---
# Auto Sync Codex Task To Server
Use this skill when a local macOS Codex task needs to keep running from a Linux server without manually re-copying files or rebuilding context each time.
## Do This
1. Run the bootstrap script against the target workspace.
2. Let the script create or reuse the Gitea repo, push the current workspace snapshot, provision the server checkout, and install background sync on both hosts.
3. Continue work from the server by reading `.codex-sync/handoff.md` and resuming inside the migrated checkout.
## Command
```bash
./scripts/run.sh \
--project-dir /absolute/path/to/workspace \
--project-name workspace-slug \
--gitea-token "$GITEA_TOKEN"
```
Use `--gitea-token-file` instead of `--gitea-token` when the token already lives on disk.
## What The Bootstrap Writes
- `.codex-sync/manifest.json`
- `.codex-sync/handoff.md`
- `.codex-sync/README.md`
- `.codex-sync/bin/sync-once.sh`
- `.codex-sync/bin/continue-task.sh`
The runtime directory `.codex-sync/runtime/` is created on demand and stays ignored by git.
## Recovery
- Inspect `.codex-sync/runtime/status.json` first when sync stops.
- Inspect `.codex-sync/runtime/last-error.log` next when the status says `blocked` or `error`.
- Clear a resolved conflict by removing `.codex-sync/runtime/blocked` and re-running `.codex-sync/bin/sync-once.sh`.
- Continue on the server with:
```bash
cd /root/continue/<project>
./.codex-sync/bin/continue-task.sh
```
Read [references/recovery.md](./references/recovery.md) for the exact recovery flow and service commands.

查看文件

@@ -0,0 +1,4 @@
interface:
display_name: "Codex Task Sync"
short_description: "Migrate a Codex workspace to a server and keep it synced"
default_prompt: "Use $codex-task-server-sync to migrate this macOS Codex workspace to my Linux server, create or reuse a Gitea repo, and keep both sides synchronized."

查看文件

@@ -0,0 +1,60 @@
# Recovery Reference
Use this reference after bootstrap when the synced project needs inspection, conflict recovery, or server-side continuation.
## Inspect Status
Run these from the migrated project root:
```bash
cat .codex-sync/manifest.json
cat .codex-sync/runtime/status.json
cat .codex-sync/runtime/last-error.log
git status -sb
```
If `status.json` says `blocked`, the last sync hit a rebase or merge conflict and automatic sync has paused itself.
## Resolve A Conflict
1. Open the project and inspect `git status`.
2. Resolve the conflict or decide which side to keep.
3. Finish the git operation or abort it explicitly.
4. Remove `.codex-sync/runtime/blocked`.
5. Re-run `.codex-sync/bin/sync-once.sh`.
## Re-run Sync Manually
```bash
./.codex-sync/bin/sync-once.sh --project-dir "$PWD"
```
Pass `--host-label local-manual` or `--host-label remote-manual` when you want the autosave commit to identify where it was triggered.
## Continue The Task On The Server
```bash
cd /root/continue/<project>
./.codex-sync/bin/continue-task.sh
```
That command starts Codex with a prompt that tells it to read `.codex-sync/handoff.md`, inspect sync status, and continue work from the server checkout.
## Service Control
The exact service names are written into `.codex-sync/manifest.json` and `.codex-sync/README.md`.
Local macOS:
```bash
launchctl print "gui/$(id -u)/<launchd-label>"
launchctl kickstart -k "gui/$(id -u)/<launchd-label>"
```
Remote Linux:
```bash
systemctl status <systemd-service> <systemd-timer>
systemctl restart <systemd-service>
systemctl restart <systemd-timer>
```

查看文件

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "$0")" && pwd)"
project_dir="$(cd "$script_dir/../.." && pwd)"
if ! command -v codex >/dev/null 2>&1; then
echo "codex is not installed or not on PATH" >&2
exit 1
fi
default_prompt="Read .codex-sync/handoff.md, inspect .codex-sync/runtime/status.json and git status -sb, then continue the task from this server checkout."
if [[ $# -gt 0 ]]; then
exec codex -C "$project_dir" "$*"
else
exec codex -C "$project_dir" "$default_prompt"
fi

查看文件

@@ -0,0 +1,273 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
from textwrap import shorten
IGNORE_PREFIXES = (
"# AGENTS.md instructions",
"<environment_context>",
"<turn_aborted>",
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Generate a Codex handoff summary for a project.")
parser.add_argument("--project-dir", required=True, help="Target project directory")
parser.add_argument("--output", required=True, help="Output markdown path")
parser.add_argument(
"--sessions-root",
default=str(Path.home() / ".codex" / "sessions"),
help="Codex sessions root directory",
)
parser.add_argument("--limit", type=int, default=4, help="Number of recent turns to include")
return parser.parse_args()
def normalize_path(path: str | Path) -> Path:
return Path(path).expanduser().resolve()
def read_first_json_line(path: Path) -> dict | None:
try:
with path.open("r", encoding="utf-8") as handle:
line = handle.readline()
return json.loads(line) if line else None
except (OSError, json.JSONDecodeError):
return None
def find_latest_session(project_dir: Path, sessions_root: Path) -> Path | None:
if not sessions_root.exists():
return None
candidates: list[tuple[float, Path]] = []
for session_file in sessions_root.rglob("*.jsonl"):
first = read_first_json_line(session_file)
if not first:
continue
cwd = first.get("payload", {}).get("cwd")
if not cwd:
continue
try:
if normalize_path(cwd) == project_dir:
candidates.append((session_file.stat().st_mtime, session_file))
except OSError:
continue
if not candidates:
return None
candidates.sort(key=lambda item: item[0], reverse=True)
return candidates[0][1]
def extract_message_text(payload: dict) -> str:
parts = []
for item in payload.get("content", []):
if item.get("type") in {"input_text", "output_text"}:
text = (item.get("text") or "").strip()
if text:
parts.append(text)
return "\n\n".join(parts).strip()
def is_substantive(text: str) -> bool:
if not text:
return False
stripped = text.strip()
if any(stripped.startswith(prefix) for prefix in IGNORE_PREFIXES):
return False
if stripped.startswith("<environment_context>") or stripped.startswith("<turn_aborted>"):
return False
return True
def compact(text: str, width: int = 220) -> str:
return shorten(" ".join(text.split()), width=width, placeholder="...")
def parse_session(session_path: Path, limit: int) -> tuple[list[str], list[str]]:
user_turns: list[str] = []
assistant_turns: list[str] = []
with session_path.open("r", encoding="utf-8") as handle:
for raw_line in handle:
try:
obj = json.loads(raw_line)
except json.JSONDecodeError:
continue
if obj.get("type") != "response_item":
continue
payload = obj.get("payload", {})
if payload.get("type") != "message":
continue
role = payload.get("role")
text = extract_message_text(payload)
if not is_substantive(text):
continue
summary = compact(text, 300 if role == "user" else 220)
if role == "user":
user_turns.append(summary)
elif role == "assistant":
assistant_turns.append(summary)
return user_turns[-limit:], assistant_turns[-limit:]
def run_git(project_dir: Path, *args: str) -> str:
result = subprocess.run(
["git", *args],
cwd=str(project_dir),
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
return ""
return result.stdout.strip()
def repo_state(project_dir: Path) -> dict[str, str]:
return {
"branch": run_git(project_dir, "rev-parse", "--abbrev-ref", "HEAD"),
"status": run_git(project_dir, "status", "--short", "--branch"),
"recent_commits": run_git(project_dir, "log", "-3", "--oneline", "--decorate", "--no-color"),
}
def render_with_session(project_dir: Path, session_path: Path, limit: int) -> str:
user_turns, assistant_turns = parse_session(session_path, limit)
state = repo_state(project_dir)
goal = user_turns[-1] if user_turns else "Continue the latest Codex task for this workspace."
lines = [
"# Codex Handoff",
"",
f"- Generated at: {datetime.now(timezone.utc).isoformat()}",
f"- Project root: `{project_dir}`",
f"- Session file: `{session_path}`",
"",
"## Goal",
"",
goal,
"",
"## Current State",
"",
f"- Branch: `{state['branch'] or 'unknown'}`",
"- Git status:",
"```text",
state["status"] or "(clean or unavailable)",
"```",
"- Recent commits:",
"```text",
state["recent_commits"] or "(no commits yet)",
"```",
"",
"## Recent User Turns",
"",
]
if user_turns:
lines.extend(f"- {turn}" for turn in user_turns)
else:
lines.append("- No substantive user turns were found in the matching session.")
lines.extend(
[
"",
"## Recent Assistant Turns",
"",
]
)
if assistant_turns:
lines.extend(f"- {turn}" for turn in assistant_turns)
else:
lines.append("- No substantive assistant turns were found in the matching session.")
next_action = (
"Open the project, inspect `.codex-sync/runtime/status.json` and `git status -sb`, then continue from the latest user request above."
)
if state["status"] and state["status"] != "## HEAD (no branch)":
next_action = (
"Read `.codex-sync/runtime/status.json`, inspect the git status block above, and continue from the latest user request while preserving any uncommitted work."
)
lines.extend(
[
"",
"## Next Action",
"",
next_action,
"",
]
)
return "\n".join(lines)
def render_fallback(project_dir: Path) -> str:
state = repo_state(project_dir)
lines = [
"# Codex Handoff",
"",
f"- Generated at: {datetime.now(timezone.utc).isoformat()}",
f"- Project root: `{project_dir}`",
"- Session file: `(no matching Codex session found)`",
"",
"## Goal",
"",
"Continue this repository from its current git state. No matching local Codex session was found for this workspace, so treat the repository contents and recent commits as the source of truth.",
"",
"## Current State",
"",
f"- Branch: `{state['branch'] or 'unknown'}`",
"- Git status:",
"```text",
state["status"] or "(clean or unavailable)",
"```",
"- Recent commits:",
"```text",
state["recent_commits"] or "(no commits yet)",
"```",
"",
"## Next Action",
"",
"Inspect the repository, read `.codex-sync/runtime/status.json`, and decide the next concrete implementation step from the current files and recent commits.",
"",
]
return "\n".join(lines)
def main() -> int:
args = parse_args()
project_dir = normalize_path(args.project_dir)
output_path = normalize_path(args.output)
sessions_root = normalize_path(args.sessions_root)
output_path.parent.mkdir(parents=True, exist_ok=True)
session_path = find_latest_session(project_dir, sessions_root)
if session_path:
content = render_with_session(project_dir, session_path, args.limit)
else:
content = render_fallback(project_dir)
output_path.write_text(content, encoding="utf-8")
return 0
if __name__ == "__main__":
sys.exit(main())

查看文件

@@ -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

查看文件

@@ -0,0 +1,217 @@
#!/usr/bin/env bash
set -euo pipefail
project_dir=""
host_label=""
usage() {
cat <<'EOF'
Usage: sync-once.sh --project-dir <path> [--host-label <label>]
Synchronize one git-backed Codex workspace against its configured origin:
- autosave dirty work into a host-stamped commit
- fetch origin
- rebase onto the configured branch
- push
- retry once after a non-fast-forward rejection
- stop and surface runtime status on conflicts
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--project-dir)
project_dir="$2"
shift 2
;;
--host-label)
host_label="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
done
if [[ -z "$project_dir" ]]; then
script_dir="$(cd "$(dirname "$0")" && pwd)"
project_dir="$(cd "$script_dir/../.." && pwd)"
fi
project_dir="$(cd "$project_dir" && pwd)"
host_label="${host_label:-$(hostname -s 2>/dev/null || hostname)}"
manifest_path="$project_dir/.codex-sync/manifest.json"
runtime_dir="$project_dir/.codex-sync/runtime"
status_path="$runtime_dir/status.json"
error_log="$runtime_dir/last-error.log"
blocked_path="$runtime_dir/blocked"
lock_dir="$runtime_dir/lock"
mkdir -p "$runtime_dir"
write_status() {
local state="$1"
local message="$2"
python3 - "$status_path" "$state" "$host_label" "$message" "$project_dir" <<'PY'
import json
import sys
from datetime import datetime, timezone
path, state, host_label, message, project_dir = sys.argv[1:]
payload = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"state": state,
"host": host_label,
"projectDir": project_dir,
"message": message,
}
with open(path, "w", encoding="utf-8") as handle:
json.dump(payload, handle, indent=2)
handle.write("\n")
PY
}
read_manifest_field() {
local expression="$1"
python3 - "$manifest_path" "$expression" <<'PY'
import json
import sys
path = sys.argv[1]
expr = sys.argv[2]
data = json.load(open(path, "r", encoding="utf-8"))
value = data
for part in expr.split("."):
value = value.get(part, "") if isinstance(value, dict) else ""
print(value if value is not None else "")
PY
}
abort_rebase_if_needed() {
if [[ -d "$project_dir/.git/rebase-apply" || -d "$project_dir/.git/rebase-merge" ]]; then
git -C "$project_dir" rebase --abort >>"$error_log" 2>&1 || true
fi
}
block_sync() {
local reason="$1"
abort_rebase_if_needed
{
printf 'timestamp=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
printf 'host=%s\n' "$host_label"
printf 'reason=%s\n' "$reason"
} >"$blocked_path"
write_status "blocked" "$reason"
exit 1
}
run_git() {
git -C "$project_dir" "$@" >>"$error_log" 2>&1
}
if [[ ! -f "$manifest_path" ]]; then
write_status "error" "Missing manifest at $manifest_path"
echo "Missing manifest at $manifest_path" >"$error_log"
exit 1
fi
if ! mkdir "$lock_dir" 2>/dev/null; then
write_status "skipped" "Another sync run is already in progress"
exit 0
fi
trap 'rmdir "$lock_dir" 2>/dev/null || true' EXIT
if [[ -f "$blocked_path" ]]; then
write_status "blocked" "Sync is blocked until .codex-sync/runtime/blocked is cleared"
exit 0
fi
: >"$error_log"
write_status "running" "Synchronizing workspace"
branch="$(read_manifest_field branch)"
git_name="$(read_manifest_field gitIdentity.name)"
git_email="$(read_manifest_field gitIdentity.email)"
branch="${branch:-main}"
git_name="${git_name:-Codex Sync}"
git_email="${git_email:-codex-sync@local}"
if [[ ! -d "$project_dir/.git" ]]; then
write_status "error" "Project is not a git repository"
echo "Project is not a git repository: $project_dir" >"$error_log"
exit 1
fi
if [[ -z "$(git -C "$project_dir" config user.name || true)" ]]; then
git -C "$project_dir" config user.name "$git_name"
fi
if [[ -z "$(git -C "$project_dir" config user.email || true)" ]]; then
git -C "$project_dir" config user.email "$git_email"
fi
if git -C "$project_dir" show-ref --verify --quiet "refs/heads/$branch"; then
git -C "$project_dir" checkout "$branch" >>"$error_log" 2>&1
else
git -C "$project_dir" checkout -B "$branch" >>"$error_log" 2>&1
fi
if [[ -n "$(git -C "$project_dir" status --porcelain)" ]]; then
git -C "$project_dir" add -A >>"$error_log" 2>&1
if ! git -C "$project_dir" diff --cached --quiet; then
stamp="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
git -C "$project_dir" commit -m "autosync(${host_label}): save work ${stamp}" >>"$error_log" 2>&1 || {
write_status "error" "Failed to commit local changes before sync"
exit 1
}
fi
fi
if ! run_git fetch origin; then
write_status "error" "Failed to fetch origin"
exit 1
fi
if git -C "$project_dir" show-ref --verify --quiet "refs/remotes/origin/$branch"; then
if ! git -C "$project_dir" rebase "origin/$branch" >>"$error_log" 2>&1; then
block_sync "Rebase conflict while replaying local commits onto origin/$branch"
fi
fi
push_ok=0
for attempt in 1 2; do
if git -C "$project_dir" push -u origin "$branch" >>"$error_log" 2>&1; then
push_ok=1
break
fi
if [[ "$attempt" -eq 2 ]]; then
break
fi
if ! run_git fetch origin; then
break
fi
if git -C "$project_dir" show-ref --verify --quiet "refs/remotes/origin/$branch"; then
if ! git -C "$project_dir" rebase "origin/$branch" >>"$error_log" 2>&1; then
block_sync "Push retry failed because origin/$branch caused a rebase conflict"
fi
fi
done
if [[ "$push_ok" -ne 1 ]]; then
write_status "error" "Push failed after retry"
exit 1
fi
rm -f "$blocked_path"
head_commit="$(git -C "$project_dir" rev-parse --short HEAD 2>/dev/null || true)"
write_status "ok" "Synchronized successfully at ${head_commit:-unknown}"