commit 83e6bb1f967e1f98b010709fae0ea9eddae69dd9 Author: NightStar Date: Wed Jul 29 19:33:02 2026 +0800 feat: 发布文件到 Gitea 仓库 — 自动隐私检查、创建公开仓库、Git推送 diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..132dfc1 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,62 @@ +--- +name: "publish-to-gitea" +description: "将文件/目录发布到 Gitea 仓库 — 自动隐私检查、创建公开仓库、Git 推送" +--- + +# Publish to Gitea — 发布到 Gitea 仓库 + +将文件/目录推送到 Gitea 仓库(git.yoresee.cc),自动隐私检查、创建公开仓库、一键推送。 + +## 触发条件 + +用户说出类似以下话语时触发: +- "把这个推送到 gitea" +- "发布到仓库" +- "创建 repo 推上去" +- "publish to git" +- "推到 git.yoresee.cc" + +## 流程 + +### 1. 确定要推送的内容 + +从上下文中推断要推送的文件/目录。常见场景: +- 用户刚创建/修改了一个 skill → 推送整个 skill 目录 +- 用户说"把这个脚本推上去" → 推送脚本文件 +- 用户指定了具体路径 → 直接用 + +### 2. 确定仓库名和描述 + +- **仓库名**:从内容推断,kebab-case 格式 +- **描述**:一句话说明用途 +- 用户没明确说则主动确认 + +### 3. 执行推送 + +```bash +cd /root/.openclaw/workspace && python3 scripts/gitea_push.py "" +``` + +脚本自动完成: +1. 🔍 隐私检查(检测 open_id、API key、token、IP 等敏感模式) +2. 📦 创建 Gitea **公开**仓库 +3. 📄 收集文件(排除 .git、node_modules 等) +4. 🚀 Git init → commit → push +5. 🔗 返回仓库 URL + +### 4. 反馈 + +推送成功后告诉用户仓库地址。 + +如果脚本检测到疑似隐私数据泄露,**必须停下来询问用户是否继续**——不可自动跳过。 + +## 安全约束 + +- ⚠️ 推送前必须过隐私检查 +- 仓库默认**公开**(public),不设 private +- 不要推送 .git 目录、node_modules、二进制文件 +- 如果之前泄露过隐私并已 amend,确认后再推 + +## 脚本依赖 + +`scripts/gitea_push.py` — Gitea 推送脚本,基于 tea CLI。 diff --git a/gitea_push.py b/gitea_push.py new file mode 100644 index 0000000..0f691e3 --- /dev/null +++ b/gitea_push.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +"""推送到 Gitea 仓库脚本 +用法: + python3 gitea_push.py + +环境变量: + GITEA_URL Gitea 实例地址(默认 https://git.yoresee.cc) + GITEA_USER Gitea 用户名(默认 NightStar) + GITEA_EMAIL Git 提交邮箱(默认 nightstar@yoresee.cc) + +示例: + python3 gitea_push.py remind-me "飞书加急提醒" ./skills/remind-me/ + python3 gitea_push.py my-tool "一个工具" ./src/ scripts/ +""" + +import json +import sys +import subprocess +import os +import re +import tempfile +from pathlib import Path + +GITEA_URL = os.environ.get("GITEA_URL", "https://git.yoresee.cc") +GITEA_USER = os.environ.get("GITEA_USER", "NightStar") +GITEA_EMAIL = os.environ.get("GITEA_EMAIL", "nightstar@yoresee.cc") +DEFAULT_BRANCH = "master" +GIT_HOST = "git.yoresee.cc" + +SANITIZE_PATTERNS = [ + (r'ou_[a-f0-9]{20,}', 'ou_xxxxxx'), # 飞书 open_id + (r'sk-[a-zA-Z0-9]{20,}', 'sk-xxxxxx'), # API key + (r't-[a-zA-Z0-9]{20,}', 't-xxxxxx'), # token + (r'cli_[a-zA-Z0-9]{10,}', 'cli_xxxxxx'), # 飞书 app_id + (r'Bearer [a-zA-Z0-9\-_\.]{20,}', 'Bearer xxxxxx'), + (r'Authorization: [a-zA-Z0-9\-_\.]{20,}', 'Authorization: xxxxxx'), + (r'[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}', 'x.x.x.x'), # IP +] + +ALWAYS_EXCLUDE = {".git", "node_modules", "__pycache__", ".DS_Store", "*.pyc", "*.pyo"} + + +def run_tea(*args): + cmd = ["tea"] + list(args) + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + raise RuntimeError(f"tea 命令失败: {' '.join(cmd)}\n{result.stderr}") + return result.stdout + + +def load_gitignore_rules(dirs): + """读取所有相关目录下的 .gitignore 并返回 pathspec""" + patterns = [] + seen = set() + for d in dirs: + p = Path(d).resolve() + while True: + gitignore = p / ".gitignore" + if str(gitignore) not in seen and gitignore.exists(): + seen.add(str(gitignore)) + try: + with open(gitignore) as f: + for line in f: + line = line.strip() + if line and not line.startswith("#"): + patterns.append(line) + except Exception: + pass + if p.parent == p: + break + p = p.parent + + try: + import pathspec + return pathspec.PathSpec.from_lines("gitwildmatch", patterns) + except ImportError: + # fallback: 简单 glob 匹配 + print(" (pathspec 未安装,使用简单 .gitignore 匹配)", file=sys.stderr) + patterns_set = set(patterns) + return patterns_set, None + + +def is_ignored(filepath, spec, src_root): + """判断文件是否应被忽略""" + name = filepath.name + # 内置排除 + if name in ALWAYS_EXCLUDE: + return True + if name.endswith(".pyc") or name.endswith(".pyo"): + return True + if name == ".gitignore": + return True + + # .gitignore 规则 + try: + rel = filepath.relative_to(src_root) + rel_str = str(rel) + except ValueError: + return False + + if spec is None: + return False + + try: + return spec.match_file(rel_str) + except Exception: + return False + + +def sanitize_file(path): + """检查文件中的隐私信息""" + try: + content = Path(path).read_text() + except Exception: + return False + + issues = [] + for pattern, replacement in SANITIZE_PATTERNS: + matches = re.findall(pattern, content) + if matches: + issues.append(f" {pattern}: 发现 {len(matches)} 处疑似隐私数据") + + if issues: + print(f"\n⚠️ 隐私检查 - {path}:", file=sys.stderr) + for issue in issues: + print(issue, file=sys.stderr) + print("", file=sys.stderr) + return True + return False + + +def collect_files(dirs, work_dir): + """收集需要推送的文件,应用 .gitignore 过滤""" + print("[3/5] 准备推送内容(应用 .gitignore 过滤)...", file=sys.stderr) + spec = load_gitignore_rules(dirs) + count = 0 + + for d in dirs: + src = Path(d).resolve() + if src.is_file(): + dest = work_dir / src.name + dest.write_bytes(src.read_bytes()) + print(f" + {src.name}", file=sys.stderr) + count += 1 + elif src.is_dir(): + for f in sorted(src.rglob("*")): + if not f.is_file(): + continue + if ".git" in f.parts: + continue + if is_ignored(f, spec, src): + continue + rel = f.relative_to(src) + dest = work_dir / rel + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(f.read_bytes()) + print(f" + {rel}", file=sys.stderr) + count += 1 + + print(f" 共收集 {count} 个文件", file=sys.stderr) + + +def main(): + if len(sys.argv) < 3: + print("用法: gitea_push.py ", file=sys.stderr) + print("示例: gitea_push.py my-tool '一个工具' ./src/", file=sys.stderr) + sys.exit(1) + + repo_name = sys.argv[1] + description = sys.argv[2] + dirs = [d for d in sys.argv[3:] if d.strip()] + + if not dirs: + print("❌ 请至少指定一个文件或目录", file=sys.stderr) + sys.exit(1) + + # 1. 隐私检查 + print("[1/5] 隐私检查...", file=sys.stderr) + has_issues = False + for d in dirs: + p = Path(d) + if p.is_file(): + has_issues |= sanitize_file(p) + elif p.is_dir(): + for f in p.rglob("*"): + if f.is_file() and f.suffix in ('.py', '.md', '.json', '.yaml', '.yml', '.sh', '.txt', '.toml', '.js', '.ts'): + has_issues |= sanitize_file(f) + if has_issues: + resp = input("⚠️ 发现疑似隐私数据,继续推送?(yes/no): ").strip().lower() + if resp not in ('yes', 'y'): + print("已取消推送", file=sys.stderr) + sys.exit(1) + + # 2. 创建 Gitea 仓库(默认公开,默认分支 master) + print(f"[2/5] 创建公开仓库 {repo_name}(分支: {DEFAULT_BRANCH})...", file=sys.stderr) + try: + output = run_tea("repo", "create", "--name", repo_name, + "--description", description, + "--branch", DEFAULT_BRANCH, + "--init") + print(output.strip(), file=sys.stderr) + except RuntimeError as e: + if "already exists" in str(e).lower() or "409" in str(e): + print(f" 仓库 {repo_name} 已存在,使用现有仓库", file=sys.stderr) + else: + raise + + # 3. 收集文件(使用 .gitignore) + work_dir = Path(tempfile.mkdtemp(prefix="gitea_")) + collect_files(dirs, work_dir) + + # 4. Git 初始化并推送到 master 分支 + print(f"[4/5] 推送到 Gitea (分支: {DEFAULT_BRANCH})...", file=sys.stderr) + subprocess.run(["git", "init", "-b", DEFAULT_BRANCH], cwd=work_dir, capture_output=True) + subprocess.run(["git", "config", "user.name", GITEA_USER], cwd=work_dir, capture_output=True) + subprocess.run(["git", "config", "user.email", GITEA_EMAIL], cwd=work_dir, capture_output=True) + subprocess.run(["git", "add", "-A"], cwd=work_dir, capture_output=True) + + commit_res = subprocess.run( + ["git", "commit", "-m", f"feat: {description}"], + cwd=work_dir, capture_output=True, text=True + ) + if commit_res.returncode != 0: + # 可能是空提交(所有文件都被过滤了) + print(f" commit 可能为空: {commit_res.stderr.strip()}", file=sys.stderr) + + repo_url = f"git@{GIT_HOST}:{GITEA_USER}/{repo_name}.git" + remote_res = subprocess.run( + ["git", "remote", "add", "origin", repo_url], + cwd=work_dir, capture_output=True, text=True + ) + if remote_res.returncode != 0: + subprocess.run(["git", "remote", "set-url", "origin", repo_url], + cwd=work_dir, capture_output=True) + + push_result = subprocess.run( + ["git", "push", "-u", "origin", DEFAULT_BRANCH, "--force"], + cwd=work_dir, + capture_output=True, text=True, + env={**os.environ, "GIT_SSH_COMMAND": "ssh -o StrictHostKeyChecking=accept-new"} + ) + if push_result.returncode != 0: + print(f"❌ 推送失败:\n{push_result.stderr[:500]}", file=sys.stderr) + sys.exit(1) + + # 5. 完成 + browse_url = f"{GITEA_URL}/{GITEA_USER}/{repo_name}" + clone_url = f"git@{GIT_HOST}:{GITEA_USER}/{repo_name}.git" + print(f"\n[5/5] ✅ 推送成功!", file=sys.stderr) + print(f" 浏览: {browse_url}", file=sys.stderr) + print(f" 克隆: {clone_url}", file=sys.stderr) + + print(json.dumps({ + "ok": True, + "repo": repo_name, + "url": browse_url, + "clone": clone_url, + "branch": DEFAULT_BRANCH + })) + + +if __name__ == "__main__": + main()