Upload 11 files
Browse files- Dockerfile +57 -0
- README.md +4 -3
- backup_daemon.py +23 -0
- backup_once.py +64 -0
- entrypoint.sh +294 -0
- nginx.conf.template +27 -0
- requirements.txt +2 -0
- start.sh +21 -0
- supervisord.conf +38 -0
- sync_dotfiles.py +111 -0
- sync_home.py +230 -0
Dockerfile
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM codercom/code-server:latest
|
| 2 |
+
|
| 3 |
+
USER root
|
| 4 |
+
|
| 5 |
+
RUN apt-get update && apt-get install -y \
|
| 6 |
+
git curl wget ca-certificates unzip jq \
|
| 7 |
+
zsh openssh-client rsync \
|
| 8 |
+
nginx apache2-utils \
|
| 9 |
+
build-essential \
|
| 10 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 11 |
+
|
| 12 |
+
# ---- Python & venv ----
|
| 13 |
+
RUN apt-get update && apt-get install -y \
|
| 14 |
+
python3 python3-venv python3-pip \
|
| 15 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 16 |
+
|
| 17 |
+
# ---- 创建虚拟环境 ----
|
| 18 |
+
RUN python3 -m venv /opt/venv
|
| 19 |
+
|
| 20 |
+
# ⭐ 设置环境变量,让虚拟环境自动激活
|
| 21 |
+
ENV PATH="/opt/venv/bin:$PATH"
|
| 22 |
+
ENV VIRTUAL_ENV=/opt/venv
|
| 23 |
+
|
| 24 |
+
# ---- 升级 pip 并安装依赖 ----
|
| 25 |
+
RUN pip install --no-cache-dir --upgrade pip \
|
| 26 |
+
&& pip install --no-cache-dir "huggingface_hub==0.26.*" \
|
| 27 |
+
&& python -c "import huggingface_hub; print('huggingface_hub=', huggingface_hub.__version__)"
|
| 28 |
+
|
| 29 |
+
# 安装 Node.js 20 LTS(替代 apt 自带的旧 node)
|
| 30 |
+
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl gnupg \
|
| 31 |
+
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
| 32 |
+
&& apt-get install -y --no-install-recommends nodejs \
|
| 33 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 34 |
+
|
| 35 |
+
# 让 npm 的 cache 和全局安装目录都落在 coder 的 HOME(后续会被你同步到 dataset)
|
| 36 |
+
ENV NPM_CONFIG_CACHE=/home/coder/.npm \
|
| 37 |
+
NPM_CONFIG_PREFIX=/home/coder/.npm-global \
|
| 38 |
+
PATH=/home/coder/.npm-global/bin:$PATH
|
| 39 |
+
|
| 40 |
+
# 确保目录存在且归属正确
|
| 41 |
+
RUN mkdir -p /home/coder/.npm /home/coder/.npm-global \
|
| 42 |
+
&& chown -R coder:coder /home/coder/.npm /home/coder/.npm-global
|
| 43 |
+
|
| 44 |
+
# 用 coder 用户安装全局 CLI(推荐)
|
| 45 |
+
USER coder
|
| 46 |
+
RUN npm i -g @cometix/codex @anthropic-ai/claude-code
|
| 47 |
+
|
| 48 |
+
# 切回 root(如果你后面还要装 nginx 等;否则可不切回)
|
| 49 |
+
USER root
|
| 50 |
+
|
| 51 |
+
COPY entrypoint.sh /entrypoint.sh
|
| 52 |
+
COPY sync_home.py /sync_home.py
|
| 53 |
+
COPY nginx.conf.template /etc/nginx/templates/nginx.conf.template
|
| 54 |
+
RUN chmod +x /entrypoint.sh
|
| 55 |
+
|
| 56 |
+
EXPOSE 7860
|
| 57 |
+
ENTRYPOINT ["/entrypoint.sh"]
|
README.md
CHANGED
|
@@ -1,10 +1,11 @@
|
|
| 1 |
---
|
| 2 |
title: Hub
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
|
|
|
| 1 |
---
|
| 2 |
title: Hub
|
| 3 |
+
emoji: 💻
|
| 4 |
+
colorFrom: gray
|
| 5 |
+
colorTo: pink
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
+
app_port: 7860
|
| 9 |
---
|
| 10 |
|
| 11 |
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
backup_daemon.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os, time, subprocess
|
| 2 |
+
|
| 3 |
+
def env_int(name, default):
|
| 4 |
+
try:
|
| 5 |
+
return int(os.getenv(name, str(default)))
|
| 6 |
+
except Exception:
|
| 7 |
+
return default
|
| 8 |
+
|
| 9 |
+
def main():
|
| 10 |
+
if os.getenv("BACKUP_ENABLE", "0") != "1":
|
| 11 |
+
print("BACKUP_ENABLE!=1,备份守护不运行")
|
| 12 |
+
return
|
| 13 |
+
|
| 14 |
+
every = env_int("BACKUP_EVERY_SECONDS", 3600)
|
| 15 |
+
warmup = env_int("BACKUP_WARMUP_SECONDS", 60)
|
| 16 |
+
time.sleep(warmup)
|
| 17 |
+
|
| 18 |
+
while True:
|
| 19 |
+
subprocess.run(["python3", "/home/user/backup_once.py"], check=False)
|
| 20 |
+
time.sleep(max(60, every))
|
| 21 |
+
|
| 22 |
+
if __name__ == "__main__":
|
| 23 |
+
main()
|
backup_once.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os, tarfile, tempfile
|
| 2 |
+
from datetime import datetime, timezone
|
| 3 |
+
from huggingface_hub import HfApi
|
| 4 |
+
|
| 5 |
+
def env_int(name, default):
|
| 6 |
+
try:
|
| 7 |
+
return int(os.getenv(name, str(default)))
|
| 8 |
+
except Exception:
|
| 9 |
+
return default
|
| 10 |
+
|
| 11 |
+
def make_tar(src_dir: str, out_path: str):
|
| 12 |
+
with tarfile.open(out_path, "w:gz") as tar:
|
| 13 |
+
tar.add(src_dir, arcname=os.path.basename(src_dir))
|
| 14 |
+
|
| 15 |
+
def main():
|
| 16 |
+
if os.getenv("BACKUP_ENABLE", "0") != "1":
|
| 17 |
+
return
|
| 18 |
+
|
| 19 |
+
token = os.getenv("HF_TOKEN") or os.getenv("HUGGINGFACE_HUB_TOKEN")
|
| 20 |
+
if not token:
|
| 21 |
+
print("HF_TOKEN 未设置,跳过备份")
|
| 22 |
+
return
|
| 23 |
+
|
| 24 |
+
dataset_repo = os.getenv("BACKUP_DATASET_REPO", "").strip()
|
| 25 |
+
if not dataset_repo:
|
| 26 |
+
print("BACKUP_DATASET_REPO 未设置,跳过备份")
|
| 27 |
+
return
|
| 28 |
+
|
| 29 |
+
src_dir = os.getenv("BACKUP_SRC_DIR", "/home/user/work")
|
| 30 |
+
keep_last = env_int("BACKUP_KEEP_LAST", 10)
|
| 31 |
+
|
| 32 |
+
api = HfApi(token=token)
|
| 33 |
+
api.create_repo(repo_id=dataset_repo, repo_type="dataset", exist_ok=True)
|
| 34 |
+
|
| 35 |
+
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
| 36 |
+
backup_name = f"backups/work-{ts}.tar.gz"
|
| 37 |
+
|
| 38 |
+
with tempfile.TemporaryDirectory() as tmp:
|
| 39 |
+
local_path = os.path.join(tmp, f"work-{ts}.tar.gz")
|
| 40 |
+
make_tar(src_dir, local_path)
|
| 41 |
+
api.upload_file(
|
| 42 |
+
path_or_fileobj=local_path,
|
| 43 |
+
path_in_repo=backup_name,
|
| 44 |
+
repo_id=dataset_repo,
|
| 45 |
+
repo_type="dataset",
|
| 46 |
+
commit_message=f"backup: {backup_name}",
|
| 47 |
+
)
|
| 48 |
+
print(f"Uploaded: {backup_name}")
|
| 49 |
+
|
| 50 |
+
files = api.list_repo_files(repo_id=dataset_repo, repo_type="dataset")
|
| 51 |
+
backups = sorted([f for f in files if f.startswith("backups/work-") and f.endswith(".tar.gz")])
|
| 52 |
+
|
| 53 |
+
if keep_last > 0 and len(backups) > keep_last:
|
| 54 |
+
for f in backups[:-keep_last]:
|
| 55 |
+
api.delete_file(
|
| 56 |
+
path_in_repo=f,
|
| 57 |
+
repo_id=dataset_repo,
|
| 58 |
+
repo_type="dataset",
|
| 59 |
+
commit_message=f"prune: {f}",
|
| 60 |
+
)
|
| 61 |
+
print(f"Deleted old backup: {f}")
|
| 62 |
+
|
| 63 |
+
if __name__ == "__main__":
|
| 64 |
+
main()
|
entrypoint.sh
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
############################################
|
| 5 |
+
# Required env vars (Space 设置):
|
| 6 |
+
# Secrets:
|
| 7 |
+
# HF_TOKEN
|
| 8 |
+
# CODE_SERVER_PASSWORD
|
| 9 |
+
# BASIC_AUTH_PASSWORD
|
| 10 |
+
# Variables:
|
| 11 |
+
# CONFIG_DATASET e.g. "yourname/ubuntu"
|
| 12 |
+
# BASIC_AUTH_USER e.g. "gally"
|
| 13 |
+
# SYNC_INTERVAL_SECONDS e.g. "300"
|
| 14 |
+
#
|
| 15 |
+
# Optional:
|
| 16 |
+
# RESET_CLI_CONFIG=1 # if dataset has no .claude/.codex, remove local remnants to force fresh bootstrap
|
| 17 |
+
############################################
|
| 18 |
+
|
| 19 |
+
: "${CONFIG_DATASET:?CONFIG_DATASET is required, e.g. yourname/ubuntu}"
|
| 20 |
+
: "${HF_TOKEN:?HF_TOKEN secret is required}"
|
| 21 |
+
: "${CODE_SERVER_PASSWORD:?CODE_SERVER_PASSWORD secret is required}"
|
| 22 |
+
: "${BASIC_AUTH_USER:?BASIC_AUTH_USER variable is required}"
|
| 23 |
+
: "${BASIC_AUTH_PASSWORD:?BASIC_AUTH_PASSWORD secret is required}"
|
| 24 |
+
: "${SYNC_INTERVAL_SECONDS:=300}"
|
| 25 |
+
|
| 26 |
+
# Use venv python (has huggingface_hub installed)
|
| 27 |
+
PY="/opt/venv/bin/python"
|
| 28 |
+
|
| 29 |
+
# Canonical HOME
|
| 30 |
+
export HOME="/home/coder"
|
| 31 |
+
|
| 32 |
+
# npm globals (claude/codex)
|
| 33 |
+
export NPM_CONFIG_CACHE="${NPM_CONFIG_CACHE:-$HOME/.npm}"
|
| 34 |
+
export NPM_CONFIG_PREFIX="${NPM_CONFIG_PREFIX:-$HOME/.npm-global}"
|
| 35 |
+
export PATH="$HOME/.npm-global/bin:$PATH"
|
| 36 |
+
mkdir -p "$NPM_CONFIG_CACHE" "$NPM_CONFIG_PREFIX"
|
| 37 |
+
chown -R coder:coder "$HOME" || true
|
| 38 |
+
|
| 39 |
+
# Make interactive shells stable
|
| 40 |
+
cat >/etc/profile.d/00-dev-env.sh <<'EOF'
|
| 41 |
+
export HOME=/home/coder
|
| 42 |
+
export NPM_CONFIG_PREFIX="$HOME/.npm-global"
|
| 43 |
+
export PATH="$HOME/.npm-global/bin:$PATH"
|
| 44 |
+
EOF
|
| 45 |
+
chmod +x /etc/profile.d/00-dev-env.sh
|
| 46 |
+
|
| 47 |
+
# De-duplicate PATH injection for bash sessions
|
| 48 |
+
if ! grep -q 'npm-global/bin' /home/coder/.bashrc 2>/dev/null; then
|
| 49 |
+
cat >>/home/coder/.bashrc <<'EOF'
|
| 50 |
+
|
| 51 |
+
# >>> dev env: npm global bin (claude/codex) >>>
|
| 52 |
+
export HOME=/home/coder
|
| 53 |
+
export NPM_CONFIG_PREFIX="$HOME/.npm-global"
|
| 54 |
+
case ":$PATH:" in
|
| 55 |
+
*":$HOME/.npm-global/bin:"*) ;;
|
| 56 |
+
*) export PATH="$HOME/.npm-global/bin:$PATH" ;;
|
| 57 |
+
esac
|
| 58 |
+
# <<< dev env <<<
|
| 59 |
+
EOF
|
| 60 |
+
fi
|
| 61 |
+
chown coder:coder /home/coder/.bashrc 2>/dev/null || true
|
| 62 |
+
|
| 63 |
+
# ---- HF auth/cache MUST stay OUT of $HOME (you sync entire HOME) ----
|
| 64 |
+
# huggingface_hub supports HF_HOME/HF_HUB_CACHE env vars. [8](https://github.com/q09sssisiwjb/Use-vscode-chrome-terminal)
|
| 65 |
+
export HF_TOKEN="${HF_TOKEN}"
|
| 66 |
+
export HF_HOME="/tmp/hf_home"
|
| 67 |
+
export HF_TOKEN_PATH="/tmp/hf_home/token"
|
| 68 |
+
export HF_HUB_CACHE="/tmp/hf_home/hub"
|
| 69 |
+
export HF_ASSETS_CACHE="/tmp/hf_home/assets"
|
| 70 |
+
|
| 71 |
+
rm -rf "${HF_HOME}" 2>/dev/null || true
|
| 72 |
+
mkdir -p "${HF_HUB_CACHE}" "${HF_ASSETS_CACHE}"
|
| 73 |
+
chmod -R 777 "${HF_HOME}" 2>/dev/null || true
|
| 74 |
+
|
| 75 |
+
echo "[debug] Using PY=${PY}"
|
| 76 |
+
"${PY}" -c "import huggingface_hub; print('[debug] huggingface_hub=', huggingface_hub.__version__)"
|
| 77 |
+
|
| 78 |
+
# ---- Pull dataset snapshot ----
|
| 79 |
+
PERSIST="/persist_repo"
|
| 80 |
+
mkdir -p "${PERSIST}"
|
| 81 |
+
|
| 82 |
+
echo "[boot] Pull dataset snapshot -> ${PERSIST}"
|
| 83 |
+
"${PY}" /sync_home.py pull --repo "${CONFIG_DATASET}" --dst "${PERSIST}"
|
| 84 |
+
|
| 85 |
+
# ---- Restore dataset home -> /home/coder (NO delete) ----
|
| 86 |
+
# Keeps image-preinstalled dirs from being wiped.
|
| 87 |
+
if [ -d "${PERSIST}/home" ]; then
|
| 88 |
+
echo "[boot] Restore ${PERSIST}/home -> ${HOME} (NO delete)"
|
| 89 |
+
"${PY}" /sync_home.py rsync_in --src "${PERSIST}/home" --dst "${HOME}"
|
| 90 |
+
else
|
| 91 |
+
echo "[boot] Dataset has no 'home/' yet. Initializing..."
|
| 92 |
+
mkdir -p "${PERSIST}/home"
|
| 93 |
+
fi
|
| 94 |
+
|
| 95 |
+
# Fix ownership after restore
|
| 96 |
+
chown -R coder:coder "${HOME}" || true
|
| 97 |
+
|
| 98 |
+
# ---- Optional: clean bootstrap of .claude/.codex if dataset lacks them ----
|
| 99 |
+
# Use if you want to ensure no local remnants remain when dataset does not have these dirs.
|
| 100 |
+
if [ "${RESET_CLI_CONFIG:-0}" = "1" ]; then
|
| 101 |
+
if [ ! -d "${PERSIST}/home/.claude" ]; then rm -rf /home/coder/.claude 2>/dev/null || true; fi
|
| 102 |
+
if [ ! -d "${PERSIST}/home/.codex" ]; then rm -rf /home/coder/.codex 2>/dev/null || true; fi
|
| 103 |
+
if [ ! -f "${PERSIST}/home/.claude.json" ]; then rm -f /home/coder/.claude.json 2>/dev/null || true; fi
|
| 104 |
+
fi
|
| 105 |
+
|
| 106 |
+
# ---- Restore .claude/.codex only if present in dataset snapshot ----
|
| 107 |
+
if [ -d "${PERSIST}/home/.claude" ]; then
|
| 108 |
+
echo "[fix] Restore ~/.claude from dataset (authoritative)"
|
| 109 |
+
mkdir -p /home/coder/.claude
|
| 110 |
+
rsync -a --delete "${PERSIST}/home/.claude/" "/home/coder/.claude/"
|
| 111 |
+
chown -R coder:coder /home/coder/.claude || true
|
| 112 |
+
else
|
| 113 |
+
echo "[fix] Dataset has no .claude -> skip restore"
|
| 114 |
+
fi
|
| 115 |
+
|
| 116 |
+
if [ -d "${PERSIST}/home/.codex" ]; then
|
| 117 |
+
echo "[fix] Restore ~/.codex from dataset (authoritative)"
|
| 118 |
+
mkdir -p /home/coder/.codex
|
| 119 |
+
rsync -a --delete "${PERSIST}/home/.codex/" "/home/coder/.codex/"
|
| 120 |
+
chown -R coder:coder /home/coder/.codex || true
|
| 121 |
+
else
|
| 122 |
+
echo "[fix] Dataset has no .codex -> skip restore"
|
| 123 |
+
fi
|
| 124 |
+
|
| 125 |
+
# ---- Claude onboarding bypass flag (user-scope file) ----
|
| 126 |
+
# Many guides suggest setting hasCompletedOnboarding=true as a TOP-LEVEL field in ~/.claude.json. [1](https://help.aliyun.com/zh/model-studio/claude-code-coding-plan)[2](https://github.com/ding113/claude-code-hub/issues/352)[3](https://linux.do/t/topic/1416398)
|
| 127 |
+
# This helps avoid onboarding/login/connectivity blockers. It does not replace API auth. [1](https://help.aliyun.com/zh/model-studio/claude-code-coding-plan)[4](https://code.claude.com/docs/en/settings)
|
| 128 |
+
if [ ! -f /home/coder/.claude.json ]; then
|
| 129 |
+
cat >/home/coder/.claude.json <<'EOF'
|
| 130 |
+
{ "hasCompletedOnboarding": true }
|
| 131 |
+
EOF
|
| 132 |
+
else
|
| 133 |
+
# Ensure the key exists at top-level (simple merge: if missing, overwrite with minimal file)
|
| 134 |
+
if ! grep -q '"hasCompletedOnboarding"[[:space:]]*:[[:space:]]*true' /home/coder/.claude.json; then
|
| 135 |
+
cat >/home/coder/.claude.json <<'EOF'
|
| 136 |
+
{ "hasCompletedOnboarding": true }
|
| 137 |
+
EOF
|
| 138 |
+
fi
|
| 139 |
+
fi
|
| 140 |
+
chown coder:coder /home/coder/.claude.json || true
|
| 141 |
+
|
| 142 |
+
# ---- Bootstrap minimal skeleton configs if absent ----
|
| 143 |
+
# Claude user settings live in ~/.claude/settings.json. [4](https://code.claude.com/docs/en/settings)[1](https://help.aliyun.com/zh/model-studio/claude-code-coding-plan)
|
| 144 |
+
mkdir -p /home/coder/.claude
|
| 145 |
+
if [ ! -f /home/coder/.claude/settings.json ]; then
|
| 146 |
+
cat >/home/coder/.claude/settings.json <<'EOF'
|
| 147 |
+
{
|
| 148 |
+
"$schema": "https://json-schema.org/claude-code-settings.json",
|
| 149 |
+
"env": {
|
| 150 |
+
"ANTHROPIC_BASE_URL": "",
|
| 151 |
+
"ANTHROPIC_AUTH_TOKEN": "",
|
| 152 |
+
"ANTHROPIC_MODEL": ""
|
| 153 |
+
},
|
| 154 |
+
"permissions": {
|
| 155 |
+
"allow": [],
|
| 156 |
+
"deny": []
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
EOF
|
| 160 |
+
fi
|
| 161 |
+
if [ ! -f /home/coder/.claude/CLAUDE.md ]; then
|
| 162 |
+
cat >/home/coder/.claude/CLAUDE.md <<'EOF'
|
| 163 |
+
# Claude Code global instructions (portable)
|
| 164 |
+
EOF
|
| 165 |
+
fi
|
| 166 |
+
chown -R coder:coder /home/coder/.claude || true
|
| 167 |
+
|
| 168 |
+
# Codex user config lives in ~/.codex/config.toml. [5](https://stackoverflow.com/questions/66496890/vs-code-nopermissions-filesystemerror-error-eacces-permission-denied)[6](https://hugging-face.cn/docs/hub/spaces-storage)
|
| 169 |
+
mkdir -p /home/coder/.codex
|
| 170 |
+
if [ ! -f /home/coder/.codex/config.toml ]; then
|
| 171 |
+
cat >/home/coder/.codex/config.toml <<'EOF'
|
| 172 |
+
# Codex user config (portable baseline)
|
| 173 |
+
# User-level configuration lives in ~/.codex/config.toml. [5](https://stackoverflow.com/questions/66496890/vs-code-nopermissions-filesystemerror-error-eacces-permission-denied)[6](https://hugging-face.cn/docs/hub/spaces-storage)
|
| 174 |
+
model_provider = "openai"
|
| 175 |
+
# model = "gpt-5.2"
|
| 176 |
+
# approval_policy = "on-request"
|
| 177 |
+
# sandbox_mode = "workspace-write"
|
| 178 |
+
EOF
|
| 179 |
+
fi
|
| 180 |
+
chown -R coder:coder /home/coder/.codex || true
|
| 181 |
+
|
| 182 |
+
# ---- Root fallback symlinks (so even processes with HOME=/root use coder configs) ----
|
| 183 |
+
rm -rf /root/.claude /root/.codex 2>/dev/null || true
|
| 184 |
+
ln -sfn /home/coder/.claude /root/.claude
|
| 185 |
+
ln -sfn /home/coder/.codex /root/.codex
|
| 186 |
+
ln -sfn /home/coder/.claude.json /root/.claude.json
|
| 187 |
+
|
| 188 |
+
# ---- Fix codex vendor binary exec bit (Codex spawns this binary) ----
|
| 189 |
+
CODEX_BIN="/home/coder/.npm-global/lib/node_modules/@cometix/codex/vendor/x86_64-unknown-linux-musl/codex/codex"
|
| 190 |
+
if [ -f "$CODEX_BIN" ]; then
|
| 191 |
+
echo "[fix] chmod +x codex vendor binary: $CODEX_BIN"
|
| 192 |
+
chmod 755 "$CODEX_BIN" || true
|
| 193 |
+
chmod 755 "$(dirname "$CODEX_BIN")" 2>/dev/null || true
|
| 194 |
+
fi
|
| 195 |
+
|
| 196 |
+
# ---- Install wrappers so claude/codex always runnable (exec-bit loss safe) ----
|
| 197 |
+
CLAUDE_JS="/home/coder/.npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js"
|
| 198 |
+
CODEX_JS="/home/coder/.npm-global/lib/node_modules/@cometix/codex/bin/codex.js"
|
| 199 |
+
|
| 200 |
+
cat >/usr/local/bin/claude <<EOF
|
| 201 |
+
#!/usr/bin/env bash
|
| 202 |
+
exec /usr/bin/node "${CLAUDE_JS}" "\$@"
|
| 203 |
+
EOF
|
| 204 |
+
chmod 755 /usr/local/bin/claude
|
| 205 |
+
|
| 206 |
+
cat >/usr/local/bin/codex <<EOF
|
| 207 |
+
#!/usr/bin/env bash
|
| 208 |
+
exec /usr/bin/node "${CODEX_JS}" "\$@"
|
| 209 |
+
EOF
|
| 210 |
+
chmod 755 /usr/local/bin/codex
|
| 211 |
+
|
| 212 |
+
# ---- Nginx basic auth ----
|
| 213 |
+
echo "Adding password for user ${BASIC_AUTH_USER}"
|
| 214 |
+
htpasswd -bc /etc/nginx/.htpasswd "${BASIC_AUTH_USER}" "${BASIC_AUTH_PASSWORD}"
|
| 215 |
+
|
| 216 |
+
if [ -f /etc/nginx/templates/nginx.conf.template ]; then
|
| 217 |
+
cp /etc/nginx/templates/nginx.conf.template /etc/nginx/nginx.conf
|
| 218 |
+
fi
|
| 219 |
+
|
| 220 |
+
# ---- code-server dirs ----
|
| 221 |
+
USER_DATA_DIR="/home/coder/.local/share/code-server"
|
| 222 |
+
EXT_DIR="/home/coder/.local/share/code-server/extensions"
|
| 223 |
+
mkdir -p "${USER_DATA_DIR}/User" "${EXT_DIR}"
|
| 224 |
+
chown -R coder:coder "${USER_DATA_DIR}" "${EXT_DIR}" || true
|
| 225 |
+
|
| 226 |
+
# 设置: create baseline ONCE, never overwrite user changes on reboot
|
| 227 |
+
# ---- VS Code user settings: create baseline ONCE, never overwrite user changes ----
|
| 228 |
+
SETTINGS_JSON="${USER_DATA_DIR}/User/settings.json"
|
| 229 |
+
mkdir -p "${USER_DATA_DIR}/User"
|
| 230 |
+
chown -R coder:coder "${USER_DATA_DIR}/User" || true
|
| 231 |
+
|
| 232 |
+
if [ ! -f "${SETTINGS_JSON}" ]; then
|
| 233 |
+
cat > "${SETTINGS_JSON}" <<'EOF'
|
| 234 |
+
{
|
| 235 |
+
"terminal.integrated.defaultProfile.linux": "SAFE_BASH",
|
| 236 |
+
"terminal.integrated.profiles.linux": {
|
| 237 |
+
"SAFE_BASH": { "path": "/bin/bash", "args": ["--noprofile", "--norc"] }
|
| 238 |
+
},
|
| 239 |
+
"terminal.integrated.cwd": "/home/coder",
|
| 240 |
+
"terminal.integrated.env.linux": {
|
| 241 |
+
"HOME": "/home/coder",
|
| 242 |
+
"NPM_CONFIG_PREFIX": "/home/coder/.npm-global",
|
| 243 |
+
"PATH": "/home/coder/.npm-global/bin:${env:PATH}"
|
| 244 |
+
},
|
| 245 |
+
"window.restoreWindows": "none"
|
| 246 |
+
}
|
| 247 |
+
EOF
|
| 248 |
+
chown coder:coder "${SETTINGS_JSON}" || true
|
| 249 |
+
else
|
| 250 |
+
echo "[boot] VS Code settings.json exists -> keep user customizations (no overwrite)"
|
| 251 |
+
fi
|
| 252 |
+
|
| 253 |
+
# ---- Start code-server (ignore last opened to avoid /root watcher EACCES) ----
|
| 254 |
+
export PASSWORD="${CODE_SERVER_PASSWORD}"
|
| 255 |
+
echo "[boot] Start code-server with explicit user-data-dir/extensions-dir"
|
| 256 |
+
su -p coder -c "export HOME=/home/coder; export PATH=/home/coder/.npm-global/bin:\$PATH; \
|
| 257 |
+
/usr/bin/code-server \
|
| 258 |
+
--bind-addr 127.0.0.1:8080 \
|
| 259 |
+
--auth password \
|
| 260 |
+
--ignore-last-opened \
|
| 261 |
+
--user-data-dir /home/coder/.local/share/code-server \
|
| 262 |
+
--extensions-dir /home/coder/.local/share/code-server/extensions \
|
| 263 |
+
/home/coder" &
|
| 264 |
+
CODE_PID=$!
|
| 265 |
+
|
| 266 |
+
# ---- Start nginx (public 7860) ----
|
| 267 |
+
nginx -g "daemon off;" &
|
| 268 |
+
NGINX_PID=$!
|
| 269 |
+
|
| 270 |
+
# ---- Sync daemon (home -> dataset) ----
|
| 271 |
+
"${PY}" /sync_home.py daemon \
|
| 272 |
+
--repo "${CONFIG_DATASET}" \
|
| 273 |
+
--home "${HOME}" \
|
| 274 |
+
--persist "${PERSIST}" \
|
| 275 |
+
--interval "${SYNC_INTERVAL_SECONDS}" &
|
| 276 |
+
SYNC_PID=$!
|
| 277 |
+
|
| 278 |
+
final_sync() {
|
| 279 |
+
echo "[sync] Final sync..."
|
| 280 |
+
"${PY}" /sync_home.py push --repo "${CONFIG_DATASET}" --home "${HOME}" --persist "${PERSIST}" || true
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
shutdown() {
|
| 284 |
+
echo "[signal] termination received"
|
| 285 |
+
final_sync
|
| 286 |
+
kill "${SYNC_PID}" 2>/dev/null || true
|
| 287 |
+
kill "${CODE_PID}" 2>/dev/null || true
|
| 288 |
+
kill "${NGINX_PID}" 2>/dev/null || true
|
| 289 |
+
exit 0
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
trap shutdown SIGTERM SIGINT
|
| 293 |
+
|
| 294 |
+
wait "${NGINX_PID}"
|
nginx.conf.template
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
worker_processes 1;
|
| 2 |
+
|
| 3 |
+
events { worker_connections 1024; }
|
| 4 |
+
|
| 5 |
+
http {
|
| 6 |
+
include /etc/nginx/mime.types;
|
| 7 |
+
default_type application/octet-stream;
|
| 8 |
+
sendfile on;
|
| 9 |
+
|
| 10 |
+
server {
|
| 11 |
+
listen 7860;
|
| 12 |
+
|
| 13 |
+
auth_basic "Restricted";
|
| 14 |
+
auth_basic_user_file /etc/nginx/.htpasswd;
|
| 15 |
+
|
| 16 |
+
location / {
|
| 17 |
+
proxy_pass http://127.0.0.1:8080;
|
| 18 |
+
proxy_http_version 1.1;
|
| 19 |
+
|
| 20 |
+
proxy_set_header Host $host;
|
| 21 |
+
proxy_set_header Upgrade $http_upgrade;
|
| 22 |
+
proxy_set_header Connection "upgrade";
|
| 23 |
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
| 24 |
+
proxy_set_header X-Forwarded-Proto $scheme;
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
jupyterlab==4.5.5
|
| 2 |
+
huggingface_hub>=0.23.0
|
start.sh
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
echo "===== Application Startup at $(date -u '+%F %T') ====="
|
| 5 |
+
|
| 6 |
+
# 站点 BasicAuth:全站一次登录
|
| 7 |
+
WEB_USER="${WEB_USER:-gally}"
|
| 8 |
+
WEB_PASSWORD="${WEB_PASSWORD:-change-me}"
|
| 9 |
+
echo "Adding password for user ${WEB_USER}"
|
| 10 |
+
htpasswd -bc /home/user/.htpasswd "$WEB_USER" "$WEB_PASSWORD"
|
| 11 |
+
|
| 12 |
+
# nginx 非 root:准备 temp 目录(必须可写)
|
| 13 |
+
mkdir -p /tmp/nginx_client_body /tmp/nginx_proxy /tmp/nginx_fastcgi /tmp/nginx_uwsgi /tmp/nginx_scgi
|
| 14 |
+
|
| 15 |
+
# 工作区:网页上传临时文件放这里(重启可丢)
|
| 16 |
+
mkdir -p /home/user/work /home/user/tmp /home/user/logs
|
| 17 |
+
|
| 18 |
+
# 启动时同步 dotfiles(只同步 ~/.claude 和 ~/.codex,不会覆盖 /home/user 根目录)
|
| 19 |
+
python3 /home/user/sync_dotfiles.py || true
|
| 20 |
+
|
| 21 |
+
exec /usr/bin/supervisord -c /home/user/supervisord.conf
|
supervisord.conf
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[supervisord]
|
| 2 |
+
nodaemon=true
|
| 3 |
+
logfile=/home/user/logs/supervisord.log
|
| 4 |
+
pidfile=/home/user/logs/supervisord.pid
|
| 5 |
+
|
| 6 |
+
[program:nginx]
|
| 7 |
+
; 用 -g 提前指定 pid 和 error_log 到 /tmp,避免非 root 写 /run 或 /var/log 报错
|
| 8 |
+
command=/usr/sbin/nginx -c /home/user/nginx.conf -g "daemon off; pid /tmp/nginx.pid; error_log /tmp/nginx_error.log info;"
|
| 9 |
+
autorestart=true
|
| 10 |
+
stdout_logfile=/home/user/logs/nginx.out.log
|
| 11 |
+
stderr_logfile=/home/user/logs/nginx.err.log
|
| 12 |
+
|
| 13 |
+
[program:code-server]
|
| 14 |
+
; 关闭 code-server 自己的认证,只保留 nginx BasicAuth → 不再二次输入密码
|
| 15 |
+
command=code-server --bind-addr 127.0.0.1:8080 --auth none
|
| 16 |
+
autorestart=true
|
| 17 |
+
stdout_logfile=/home/user/logs/codeserver.out.log
|
| 18 |
+
stderr_logfile=/home/user/logs/codeserver.err.log
|
| 19 |
+
|
| 20 |
+
[program:jupyter]
|
| 21 |
+
; 关闭 token/password,避免二次认证;反代子路径 base_url=/jupyter/ 是常见配置
|
| 22 |
+
command=python3 -m jupyterlab --no-browser --ip=127.0.0.1 --port=8888 --ServerApp.base_url=/jupyter/ --ServerApp.allow_remote_access=True --ServerApp.root_dir=/home/user/work --ServerApp.token='' --ServerApp.password=''
|
| 23 |
+
autorestart=true
|
| 24 |
+
stdout_logfile=/home/user/logs/jupyter.out.log
|
| 25 |
+
stderr_logfile=/home/user/logs/jupyter.err.log
|
| 26 |
+
|
| 27 |
+
[program:ttyd]
|
| 28 |
+
; ttyd 1.6.3 不支持 -W,使用最兼容命令
|
| 29 |
+
command=ttyd -p 7681 bash
|
| 30 |
+
autorestart=true
|
| 31 |
+
stdout_logfile=/home/user/logs/ttyd.out.log
|
| 32 |
+
stderr_logfile=/home/user/logs/ttyd.err.log
|
| 33 |
+
|
| 34 |
+
[program:backup-daemon]
|
| 35 |
+
command=python3 /home/user/backup_daemon.py
|
| 36 |
+
autorestart=true
|
| 37 |
+
stdout_logfile=/home/user/logs/backup.out.log
|
| 38 |
+
stderr_logfile=/home/user/logs/backup.err.log
|
sync_dotfiles.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import subprocess
|
| 3 |
+
from huggingface_hub import snapshot_download
|
| 4 |
+
|
| 5 |
+
# 绝对敏感:不同步
|
| 6 |
+
SENSITIVE_EXCLUDES = [
|
| 7 |
+
"auth.json",
|
| 8 |
+
".sandbox-secrets/**",
|
| 9 |
+
]
|
| 10 |
+
|
| 11 |
+
# 建议不同步的大缓存/易变目录(减少文件数 & vanished)
|
| 12 |
+
HEAVY_EXCLUDES = [
|
| 13 |
+
"plugins/cache/**",
|
| 14 |
+
"projects/**",
|
| 15 |
+
"shell-snapshots/**",
|
| 16 |
+
"statsig/**",
|
| 17 |
+
"todos/**",
|
| 18 |
+
"log/**",
|
| 19 |
+
"tmp/**",
|
| 20 |
+
"sessions/**",
|
| 21 |
+
]
|
| 22 |
+
|
| 23 |
+
def run_rsync(src: str, dst: str, excludes=None, delete=True):
|
| 24 |
+
excludes = excludes or []
|
| 25 |
+
os.makedirs(dst, exist_ok=True)
|
| 26 |
+
cmd = ["rsync", "-a"]
|
| 27 |
+
if delete:
|
| 28 |
+
cmd.append("--delete")
|
| 29 |
+
for ex in excludes:
|
| 30 |
+
cmd += ["--exclude", ex]
|
| 31 |
+
cmd += [src.rstrip("/") + "/", dst.rstrip("/") + "/"]
|
| 32 |
+
p = subprocess.run(cmd)
|
| 33 |
+
# rsync code 24: some files vanished (often due to changing caches). Treat as warning.
|
| 34 |
+
if p.returncode not in (0, 24):
|
| 35 |
+
raise RuntimeError(f"rsync failed with code {p.returncode}")
|
| 36 |
+
|
| 37 |
+
def main():
|
| 38 |
+
repo = os.getenv("DOTFILES_DATASET_REPO", "").strip()
|
| 39 |
+
if not repo:
|
| 40 |
+
print("DOTFILES_DATASET_REPO 未设置,跳过 dotfiles 同步")
|
| 41 |
+
return
|
| 42 |
+
|
| 43 |
+
token = os.getenv("HF_TOKEN") or os.getenv("HUGGINGFACE_HUB_TOKEN")
|
| 44 |
+
if not token:
|
| 45 |
+
print("HF_TOKEN 未设置(Private Dataset 必需),跳过 dotfiles 同步")
|
| 46 |
+
return
|
| 47 |
+
|
| 48 |
+
# 只下载你需要的目录,避免拉 1500+ 文件
|
| 49 |
+
allow = [
|
| 50 |
+
"ubuntu/home/.claude/**",
|
| 51 |
+
"ubuntu/home/.codex/**",
|
| 52 |
+
"dotfiles/home/.claude/**",
|
| 53 |
+
"dotfiles/home/.codex/**",
|
| 54 |
+
"ubuntu/project/**",
|
| 55 |
+
"dotfiles/project/**",
|
| 56 |
+
"project/**",
|
| 57 |
+
]
|
| 58 |
+
|
| 59 |
+
local_dir = snapshot_download(
|
| 60 |
+
repo_id=repo,
|
| 61 |
+
repo_type="dataset",
|
| 62 |
+
allow_patterns=allow,
|
| 63 |
+
token=token,
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
# 兼容你的结构:优先 ubuntu/home
|
| 67 |
+
cand_home = [
|
| 68 |
+
os.path.join(local_dir, "ubuntu", "home"),
|
| 69 |
+
os.path.join(local_dir, "dotfiles", "home"),
|
| 70 |
+
os.path.join(local_dir, "home"),
|
| 71 |
+
]
|
| 72 |
+
home_root = next((p for p in cand_home if os.path.isdir(p)), None)
|
| 73 |
+
|
| 74 |
+
if not home_root:
|
| 75 |
+
print("未找到 home 根目录(例如 ubuntu/home)")
|
| 76 |
+
return
|
| 77 |
+
|
| 78 |
+
claude_src = os.path.join(home_root, ".claude")
|
| 79 |
+
codex_src = os.path.join(home_root, ".codex")
|
| 80 |
+
|
| 81 |
+
# 只同步这两个目录,不碰 /home/user 其它文件
|
| 82 |
+
if os.path.isdir(claude_src):
|
| 83 |
+
print(f"Sync ~/.claude: {claude_src} -> /home/user/.claude")
|
| 84 |
+
run_rsync(claude_src, "/home/user/.claude", excludes=HEAVY_EXCLUDES, delete=True)
|
| 85 |
+
else:
|
| 86 |
+
print("未找到 .claude 目录,跳过")
|
| 87 |
+
|
| 88 |
+
if os.path.isdir(codex_src):
|
| 89 |
+
excludes = SENSITIVE_EXCLUDES + HEAVY_EXCLUDES
|
| 90 |
+
print(f"Sync ~/.codex: {codex_src} -> /home/user/.codex (exclude auth/secrets)")
|
| 91 |
+
run_rsync(codex_src, "/home/user/.codex", excludes=excludes, delete=True)
|
| 92 |
+
else:
|
| 93 |
+
print("未找到 .codex 目录,跳过")
|
| 94 |
+
|
| 95 |
+
# 可选:项目级配置(如果你未来加了)
|
| 96 |
+
cand_proj = [
|
| 97 |
+
os.path.join(local_dir, "ubuntu", "project"),
|
| 98 |
+
os.path.join(local_dir, "dotfiles", "project"),
|
| 99 |
+
os.path.join(local_dir, "project"),
|
| 100 |
+
]
|
| 101 |
+
proj_src = next((p for p in cand_proj if os.path.isdir(p)), None)
|
| 102 |
+
if proj_src:
|
| 103 |
+
print(f"Sync project: {proj_src} -> /home/user/work")
|
| 104 |
+
run_rsync(proj_src, "/home/user/work", excludes=[], delete=True)
|
| 105 |
+
else:
|
| 106 |
+
print("未找到 PROJECT dotfiles 源目录(可忽略)")
|
| 107 |
+
|
| 108 |
+
print("Dotfiles sync done.")
|
| 109 |
+
|
| 110 |
+
if __name__ == "__main__":
|
| 111 |
+
main()
|
sync_home.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
import os
|
| 3 |
+
import subprocess
|
| 4 |
+
import time
|
| 5 |
+
import shutil
|
| 6 |
+
from huggingface_hub import snapshot_download, HfApi
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
# Hugging Face Hub commit validation forbids pushing files under certain folder names,
|
| 10 |
+
# including ".cache". If we try to upload home/.cache/** we will get:
|
| 11 |
+
# "Invalid path_in_repo ... cannot update files under a '.cache/' folder".
|
| 12 |
+
# This is enforced server-side / client-side validation (FORBIDDEN_FOLDERS). [1](https://github.com/huggingface/huggingface_hub/blob/main/src/huggingface_hub/_commit_api.py)
|
| 13 |
+
FORCED_EXCLUDES = [".cache"]
|
| 14 |
+
|
| 15 |
+
# Optional default excludes to keep repo size reasonable.
|
| 16 |
+
# NOTE: Do NOT exclude code-server extensions/User if you want them persisted.
|
| 17 |
+
DEFAULT_EXCLUDES = [
|
| 18 |
+
# huge and usually not worth versioning
|
| 19 |
+
"node_modules",
|
| 20 |
+
"__pycache__",
|
| 21 |
+
".local/share/Trash",
|
| 22 |
+
|
| 23 |
+
# optional caches (keep if you want full persistence; remove from here if desired)
|
| 24 |
+
# ".npm/_cacache", # many users exclude this; you may keep it if you want
|
| 25 |
+
# ".local/share/code-server/Cache",
|
| 26 |
+
# ".local/share/code-server/CachedData",
|
| 27 |
+
# ".local/share/code-server/GPUCache",
|
| 28 |
+
# ".local/share/code-server/logs",
|
| 29 |
+
]
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def run(cmd):
|
| 33 |
+
subprocess.check_call(cmd)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def capture(cmd):
|
| 37 |
+
return subprocess.check_output(cmd, text=True, stderr=subprocess.STDOUT)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def parse_excludes():
|
| 41 |
+
"""
|
| 42 |
+
Excludes come from:
|
| 43 |
+
- DEFAULT_EXCLUDES ((可选))
|
| 44 |
+
- SYNC_EXCLUDES env var: comma-separated patterns
|
| 45 |
+
- FORCED_EXCLUDES: always enforced (currently ".cache")
|
| 46 |
+
If SYNC_DISABLE_EXCLUDES=1, we still enforce FORCED_EXCLUDES because Hub rejects ".cache".
|
| 47 |
+
[1](https://github.com/huggingface/huggingface_hub/blob/main/src/huggingface_hub/_commit_api.py)
|
| 48 |
+
"""
|
| 49 |
+
disable = os.environ.get("SYNC_DISABLE_EXCLUDES") == "1"
|
| 50 |
+
extra_raw = os.environ.get("SYNC_EXCLUDES", "").strip()
|
| 51 |
+
|
| 52 |
+
excludes = []
|
| 53 |
+
if not disable:
|
| 54 |
+
excludes.extend(DEFAULT_EXCLUDES)
|
| 55 |
+
if extra_raw:
|
| 56 |
+
excludes.extend([x.strip() for x in extra_raw.split(",") if x.strip()])
|
| 57 |
+
|
| 58 |
+
# Always enforce forbidden folders excludes
|
| 59 |
+
excludes.extend(FORCED_EXCLUDES)
|
| 60 |
+
|
| 61 |
+
# de-dup while preserving order
|
| 62 |
+
seen = set()
|
| 63 |
+
out = []
|
| 64 |
+
for e in excludes:
|
| 65 |
+
if e not in seen:
|
| 66 |
+
seen.add(e)
|
| 67 |
+
out.append(e)
|
| 68 |
+
return out
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def rsync(src: str, dst: str, delete: bool):
|
| 72 |
+
excludes = parse_excludes()
|
| 73 |
+
cmd = ["rsync", "-a"]
|
| 74 |
+
|
| 75 |
+
if delete:
|
| 76 |
+
cmd.append("--delete")
|
| 77 |
+
|
| 78 |
+
for pat in excludes:
|
| 79 |
+
cmd += ["--exclude", pat]
|
| 80 |
+
|
| 81 |
+
cmd += [src.rstrip("/") + "/", dst.rstrip("/") + "/"]
|
| 82 |
+
run(cmd)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def rsync_has_changes(src: str, dst: str, delete: bool) -> bool:
|
| 86 |
+
"""
|
| 87 |
+
Detect whether an rsync would change anything (to skip empty commits).
|
| 88 |
+
"""
|
| 89 |
+
excludes = parse_excludes()
|
| 90 |
+
cmd = ["rsync", "-a", "--dry-run", "--itemize-changes"]
|
| 91 |
+
if delete:
|
| 92 |
+
cmd.append("--delete")
|
| 93 |
+
for pat in excludes:
|
| 94 |
+
cmd += ["--exclude", pat]
|
| 95 |
+
cmd += [src.rstrip("/") + "/", dst.rstrip("/") + "/"]
|
| 96 |
+
|
| 97 |
+
try:
|
| 98 |
+
out = capture(cmd)
|
| 99 |
+
except subprocess.CalledProcessError as e:
|
| 100 |
+
# if dry-run fails, be conservative and say "has changes"
|
| 101 |
+
return True
|
| 102 |
+
|
| 103 |
+
# rsync prints one line per changed item; ignore empty output
|
| 104 |
+
return any(line.strip() for line in out.splitlines())
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def pull(repo: str, dst: str):
|
| 108 |
+
"""
|
| 109 |
+
Download dataset repo snapshot into dst.
|
| 110 |
+
"""
|
| 111 |
+
os.makedirs(dst, exist_ok=True)
|
| 112 |
+
|
| 113 |
+
# snapshot_download uses a local cache; its location is controlled by HF_HOME/HF_HUB_CACHE env vars. [2](https://huggingface.co/docs/huggingface_hub/guides/manage-cache)
|
| 114 |
+
snapshot_download(
|
| 115 |
+
repo_id=repo,
|
| 116 |
+
repo_type="dataset",
|
| 117 |
+
local_dir=dst,
|
| 118 |
+
local_dir_use_symlinks=False, # kept for compatibility with older versions; ignored in newer versions
|
| 119 |
+
token=os.environ.get("HF_TOKEN"),
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def rsync_in(src: str, dst: str):
|
| 124 |
+
"""
|
| 125 |
+
dataset -> home
|
| 126 |
+
DO NOT delete by default (avoid wiping image-preinstalled dirs such as .npm-global).
|
| 127 |
+
"""
|
| 128 |
+
rsync(src, dst, delete=False)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def rsync_out(home: str, persist_home: str):
|
| 132 |
+
"""
|
| 133 |
+
home -> dataset snapshot folder
|
| 134 |
+
Use delete=True to keep dataset/home consistent with current home,
|
| 135 |
+
but always exclude ".cache" (Hub rejects it). [1](https://github.com/huggingface/huggingface_hub/blob/main/src/huggingface_hub/_commit_api.py)
|
| 136 |
+
"""
|
| 137 |
+
rsync(home, persist_home, delete=True)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def sanitize_forbidden(persist: str):
|
| 141 |
+
"""
|
| 142 |
+
Remove forbidden folders if present in persist/home before upload.
|
| 143 |
+
Currently: persist/home/.cache
|
| 144 |
+
"""
|
| 145 |
+
forbidden_path = os.path.join(persist, "home", ".cache")
|
| 146 |
+
shutil.rmtree(forbidden_path, ignore_errors=True)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def push_repo(repo: str, persist: str):
|
| 150 |
+
"""
|
| 151 |
+
Upload persist folder back to dataset repo.
|
| 152 |
+
"""
|
| 153 |
+
sanitize_forbidden(persist)
|
| 154 |
+
|
| 155 |
+
api = HfApi(token=os.environ.get("HF_TOKEN"))
|
| 156 |
+
|
| 157 |
+
# ignore_patterns provides another safety layer so that even if something slipped in,
|
| 158 |
+
# it won't be included in the commit operation.
|
| 159 |
+
api.upload_folder(
|
| 160 |
+
repo_id=repo,
|
| 161 |
+
repo_type="dataset",
|
| 162 |
+
folder_path=persist,
|
| 163 |
+
path_in_repo="",
|
| 164 |
+
commit_message=f"sync home: {time.strftime('%Y-%m-%d %H:%M:%S')}",
|
| 165 |
+
ignore_patterns=[
|
| 166 |
+
"home/.cache/**",
|
| 167 |
+
".cache/**",
|
| 168 |
+
],
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def push(repo: str, home: str, persist: str):
|
| 173 |
+
"""
|
| 174 |
+
home -> persist/home via rsync, then upload persist to Hub
|
| 175 |
+
"""
|
| 176 |
+
persist_home = os.path.join(persist, "home")
|
| 177 |
+
os.makedirs(persist_home, exist_ok=True)
|
| 178 |
+
|
| 179 |
+
# If nothing changed, skip commit to avoid empty commits
|
| 180 |
+
if not rsync_has_changes(home, persist_home, delete=True):
|
| 181 |
+
print("No files have been modified since last commit. Skipping to prevent empty commit.")
|
| 182 |
+
return
|
| 183 |
+
|
| 184 |
+
rsync_out(home, persist_home)
|
| 185 |
+
push_repo(repo, persist)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def daemon(repo: str, home: str, persist: str, interval: int):
|
| 189 |
+
while True:
|
| 190 |
+
try:
|
| 191 |
+
push(repo, home, persist)
|
| 192 |
+
print(f"[sync] pushed OK. next in {interval}s")
|
| 193 |
+
except Exception as e:
|
| 194 |
+
print(f"[sync] push failed: {e}")
|
| 195 |
+
time.sleep(interval)
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
if __name__ == "__main__":
|
| 199 |
+
ap = argparse.ArgumentParser()
|
| 200 |
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
| 201 |
+
|
| 202 |
+
p_pull = sub.add_parser("pull")
|
| 203 |
+
p_pull.add_argument("--repo", required=True)
|
| 204 |
+
p_pull.add_argument("--dst", required=True)
|
| 205 |
+
|
| 206 |
+
p_in = sub.add_parser("rsync_in")
|
| 207 |
+
p_in.add_argument("--src", required=True)
|
| 208 |
+
p_in.add_argument("--dst", required=True)
|
| 209 |
+
|
| 210 |
+
p_push = sub.add_parser("push")
|
| 211 |
+
p_push.add_argument("--repo", required=True)
|
| 212 |
+
p_push.add_argument("--home", required=True)
|
| 213 |
+
p_push.add_argument("--persist", required=True)
|
| 214 |
+
|
| 215 |
+
p_daemon = sub.add_parser("daemon")
|
| 216 |
+
p_daemon.add_argument("--repo", required=True)
|
| 217 |
+
p_daemon.add_argument("--home", required=True)
|
| 218 |
+
p_daemon.add_argument("--persist", required=True)
|
| 219 |
+
p_daemon.add_argument("--interval", type=int, default=300)
|
| 220 |
+
|
| 221 |
+
args = ap.parse_args()
|
| 222 |
+
|
| 223 |
+
if args.cmd == "pull":
|
| 224 |
+
pull(args.repo, args.dst)
|
| 225 |
+
elif args.cmd == "rsync_in":
|
| 226 |
+
rsync_in(args.src, args.dst)
|
| 227 |
+
elif args.cmd == "push":
|
| 228 |
+
push(args.repo, args.home, args.persist)
|
| 229 |
+
elif args.cmd == "daemon":
|
| 230 |
+
daemon(args.repo, args.home, args.persist, args.interval)
|