gallyg commited on
Commit
632b0a7
·
verified ·
1 Parent(s): 245fbe0

Upload 11 files

Browse files
Files changed (11) hide show
  1. Dockerfile +57 -0
  2. README.md +4 -3
  3. backup_daemon.py +23 -0
  4. backup_once.py +64 -0
  5. entrypoint.sh +294 -0
  6. nginx.conf.template +27 -0
  7. requirements.txt +2 -0
  8. start.sh +21 -0
  9. supervisord.conf +38 -0
  10. sync_dotfiles.py +111 -0
  11. 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: blue
5
- colorTo: indigo
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)