Add codex-task-server-sync skill
这个提交包含在:
@@ -15,6 +15,7 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `captcha-third-party-services` | 统一封装 2Captcha / YesCaptcha / Anti-Captcha 的官方 API 工作流。 |
|
| `captcha-third-party-services` | 统一封装 2Captcha / YesCaptcha / Anti-Captcha 的官方 API 工作流。 |
|
||||||
| `cliproxy-traffic-proxy` | CLIProxy 流量代理相关技能。 |
|
| `cliproxy-traffic-proxy` | CLIProxy 流量代理相关技能。 |
|
||||||
|
| `codex-task-server-sync` | 把本地 Codex 工作区迁移到服务器并通过 Gitea 持续双向同步。 |
|
||||||
| `cn86-sms-keyword-verification` | 86 手机号 + 关键词验证码流程,基于 LubanSMS API 完成取号、取码、提码、释放号码。 |
|
| `cn86-sms-keyword-verification` | 86 手机号 + 关键词验证码流程,基于 LubanSMS API 完成取号、取码、提码、释放号码。 |
|
||||||
| `email-verification` | 邮箱验证码获取服务,支持临时邮箱 API 拉取验证码、链接和邮件内容。 |
|
| `email-verification` | 邮箱验证码获取服务,支持临时邮箱 API 拉取验证码、链接和邮件内容。 |
|
||||||
| `gitea-repo-sync` | 在 Gitea 上创建仓库并把本地项目安全同步过去。 |
|
| `gitea-repo-sync` | 在 Gitea 上创建仓库并把本地项目安全同步过去。 |
|
||||||
|
|||||||
49
codex-task-server-sync/SKILL.md
普通文件
49
codex-task-server-sync/SKILL.md
普通文件
@@ -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())
|
||||||
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
|
||||||
@@ -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}"
|
||||||
在新工单中引用
屏蔽一个用户