Initial commit

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
timmy
2026-04-21 13:04:09 +08:00
commit faee15bc78
18 changed files with 3278 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
config.env

109
CLAUDE.md Normal file
View File

@@ -0,0 +1,109 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 專案性質
這是一個 **Claude Code skill**(根目錄的 `SKILL.md` 就是 skill 定義檔)。本身不是一般應用程式,產物是一組 bash 腳本(`scripts/*.sh`),針對自架 Gitea 做常見操作。`publish.sh` 是最早的腳本,其他腳本是逐步擴充而來。
## 常用指令
在本 repo 裡沒有測試buildlint開發流程就是`scripts/*.sh``bash -n` 語法檢查 → 在另一個 tmp 目錄實際跑一次看看。所有腳本共用 `scripts/lib/common.sh`
實際使用腳本時(在任何專案目錄下):
```bash
bash /path/to/gitea-cli/scripts/publish.sh [repo] [public|private] [-m "msg"]
bash /path/to/gitea-cli/scripts/list.sh [--private|--public] [--format table|tsv|name]
# ... 其他腳本見 SKILL.md / README.md
```
首次使用前要 `cp config.example.env config.env` 並填入 `GITEA_URL` / `GITEA_USER` / `GITEA_TOKEN`
## 架構與關鍵不變量
### 安全模型token 絕不落地
**修改任何腳本時都要守住**
1. `origin` remote 永遠是**乾淨 URL**,不含 token
2. Token 只能出現在 `config.env`、呼叫過程的環境變數、以及 `scripts/lib/common.sh` 裡的 `gitea_api` / `git_*_with_token` 函式內
3. Token 絕不進入 `.git/config`、remote URL、任何 logstdout/stderr
4. `config.env``.gitignore` 排除
### 共用 lib `scripts/lib/common.sh`
所有 API / push / clone 都走 lib腳本本身不寫 curl 也不直接操作 credential helper。lib 提供:
- `load_config`:讀 `config.env`,檢查三個變數都非空非預設
- `gitea_api <METHOD> <path> [json-body]`:單一 API 呼叫入口;回傳後讀 `$HTTP_CODE``$GITEA_API_RESP_FILE`
- `git_push_with_token <branch>` / `git_push_tag_with_token <tag>` / `git_clone_with_token <clean-url> <target>`:一次性帶 token 的 git 操作
- `infer_repo_from_cwd`:從 `git remote get-url origin` 推斷 `owner/repo`
- `confirm_destructive <prompt>` / `confirm_exact_match <expected>`:破壞性操作互動;`GITEA_YES=1` 或腳本處理的 `-y` 可略過
- `log_info` / `log_ok` / `log_warn` / `log_err` / `log_done`:統一前綴、**全部寫 stderr**
**什麼不放進 lib**(刻意留在各腳本):
- 各腳本自己的 arg 解析(每支不同)
- JSON body 組字串(放在呼叫處最直觀;複雜的用 jq 在呼叫端組)
- `publish.sh` 的三種 git 狀態分支publish 獨有)
### stdout / stderr 分流
- **stderr**`log_*` 進度訊息、錯誤
- **stdout**:最終結構化輸出
- `publish.sh` / `rename.sh` / `visibility.sh` / `archive.sh` / `meta.sh` → repo web URL
- `list.sh` → repo 清單
- `clone.sh` → clone 到的絕對路徑
- `release.sh` → release URL + 每個 asset URL
- `tag.sh` → tag 名
- `delete.sh` → 空
使用者可以 `URL=$(publish.sh)` 拿結果。
### 新腳本骨架(照抄)
寫新腳本時以這個為起點:
```bash
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
load_config
# require_cmd jq # 若會用到
usage() {
cat >&2 <<'EOF'
Usage: <script>.sh ...
EOF
exit 1
}
# arg parsing
# ...
log_info "..."
gitea_api METHOD "/path" "$BODY"
case "$HTTP_CODE" in
2??) log_ok "..." ;;
404) log_err "..."; exit 1 ;;
*) log_err "... (HTTP ${HTTP_CODE})"; { cat "$GITEA_API_RESP_FILE"; echo ""; } >&2; exit 1 ;;
esac
log_done "完成"
echo "<結構化結果>" # stdout
```
### 相容性限制
使用 **Gitea API v1**Forgejo 相容GitHub / GitLab **不相容**。要擴充到其他平台要重寫 API 呼叫處。
## 寫入偏好
- 腳本訊息與使用者文件皆為繁體中文(台灣用語)
- 沿用既有風格:▶ 進行中、✓ 成功、⚠ 警告、❌ 失敗、✅ 完成
- `SKILL.md` 是給 Claude 讀的 skill metadata + 簡短說明;`README.md` 是給人看的完整文件。修改功能時兩份都要同步
- 範例路徑用通用形式(`/path/to/gitea-cli`),不要硬編碼個人資訊

120
README.md Normal file
View File

@@ -0,0 +1,120 @@
# gitea-cli
一組 bash 腳本,把自架 Gitea 的常見操作封裝成單一指令;目前實作「建立 repo → init → commit → push」其他功能list / clone / rename / release / …)依設計文件擴充中。
## 檔案結構
```
gitea-cli/
├── SKILL.md # Claude Code skill 定義
├── README.md # 本檔
├── CLAUDE.md # 給 Claude 的 repo 內指引
├── config.example.env # 設定範本(可提交)
├── config.env # 實際設定(內含 token.gitignore 排除)
├── .gitignore
├── docs/
│ └── superpowers/specs/ # 擴充設計文件
└── scripts/
└── publish.sh # 建 repo + 推送
```
> 以下範例用 `/path/to/gitea-cli` 代表本 skill 實際所在位置(你 clone 到哪裡就是哪裡);也可以 symlink 到 `~/.claude/skills/gitea-cli` 讓 Claude Code 自動識別。
## 首次設定
1. **產生 Gitea access token**
登入 Gitea → 使用者設定 → Applications → Generate New Token
URL `<你的 Gitea URL>/-/user/settings/applications`
權限至少勾選:**write:repository**
2. **複製設定檔並填入 token**
```bash
cd /path/to/gitea-cli
cp config.example.env config.env
# 用任一編輯器編輯 config.env填入三個變數
```
`config.env` 範例(請替換為自己的值):
```env
GITEA_URL=http://your-gitea.example.com:3000
GITEA_USER=your-username
GITEA_TOKEN=你剛才產生的_token
```
3. (選用)讓 Claude Code 自動辨識這個 skill
```bash
ln -s /path/to/gitea-cli ~/.claude/skills/gitea-cli
```
## 使用方式
在要推送的專案目錄下執行:
```bash
# 用當前資料夾名當 repo 名,建立 public repo
bash /path/to/gitea-cli/scripts/publish.sh
# 指定 repo 名
bash /path/to/gitea-cli/scripts/publish.sh my-project
# 建立 private repo
bash /path/to/gitea-cli/scripts/publish.sh my-project private
```
| 參數 | 說明 | 預設 |
|------|------|------|
| `$1` | repo 名稱 | 當前目錄名 |
| `$2` | 可見性:`public` / `private` | `public` |
## 所有可用腳本
| 腳本 | 用法 |
|---|---|
| `publish.sh` | `publish.sh [repo] [public\|private] [-m "msg"]` — 建 repo + 推目前目錄 |
| `list.sh` | `list.sh [--private\|--public] [--format table\|tsv\|name]` — 列出自己的 repo |
| `clone.sh` | `clone.sh <repo> [target-dir]` — clone 到本地origin 保持乾淨 URL |
| `rename.sh` | `rename.sh <old> <new>` — 改名;若 cwd origin 指到舊 repo 自動更新 |
| `visibility.sh` | `visibility.sh <repo> <public\|private>` — 切換可見性 |
| `meta.sh` | `meta.sh <repo> [--desc ...] [--topics a,b,c]` — 設定 description / topics |
| `archive.sh` | `archive.sh <repo> [--unarchive] [-y]` — archive / unarchive |
| `delete.sh` | `delete.sh <repo> [-y]` — 刪除(互動模式需重新輸入 repo 名) |
| `tag.sh` | `tag.sh <tag> [-m "msg"]` — 建 annotated tag 並 push origin |
| `release.sh` | `release.sh <tag> [--name ...] [--notes ...] [--asset PATH]... [--repo NAME]` — 建 release + 上傳 asset |
所有呼叫 API 的腳本共用 `scripts/lib/common.sh`,該 lib 負責 config 載入、API 呼叫、一次性 credential helper 注入、互動確認。
## 腳本執行步驟
1. 讀取 `config.env`,驗證三個必要環境變數都有值
2. 呼叫 Gitea API `POST /api/v1/user/repos` 建立 repo
- `201` 建立成功
- `409` repo 已存在,略過
- `401/403` 授權錯誤,提示檢查 token
3. 若當前目錄沒有 `.git` → `git init -b main`
4. 若還沒有任何 commit → 自動建 initial commit含空 commit 以支援空目錄)
5. 若有未提交變更 → `git add -A && git commit -m "Update"`
6. 設定 `origin` 為**不含 token** 的乾淨 URL
7. 用一次性 credential helper 帶 token 執行 `git push -u origin <branch>`
## 安全設計
- **`config.env` 內含 token`.gitignore` 已排除**,不會被提交。
- **token 不寫入 `.git/config`**`origin` 為乾淨 URLpush 時用 `git -c credential.helper=...` 注入,結束後即消失。
- Credential helper 以變數替換方式產生 shell 片段;命令列參數會短暫出現在 `ps` 中,僅限本機使用者看到(自架 LAN Gitea 場景可接受)。
- `clone.sh` 完成後 origin 也是乾淨 URL再次 `git pull` / `git push` 要重新帶 credential跟 `publish.sh` 一致)。
## 常見問題
**Q推送完之後再跑 `git push`,為什麼失敗?**
Aorigin 是乾淨 URL再次 push 會要求認證。解法二選一:
- 再用本腳本 push會重新帶 token
- 自行設定 git credential helper 記住帳密
**QToken 失效/過期?**
A重新到 Gitea 產生 token覆蓋 `config.env` 裡的 `GITEA_TOKEN`。
**Q要換成另一個 Gitea 站或使用者?**
A直接改 `config.env` 的 `GITEA_URL` / `GITEA_USER`。
**Q可以用在非 Gitea例如 Gitea clone 產品)嗎?**
A本腳本用的是 Gitea API v1Forgejo 相容GitHub/GitLab 不相容。

47
SKILL.md Normal file
View File

@@ -0,0 +1,47 @@
---
name: gitea-cli
description: 對自架 Gitea 做常見操作:建 repo 並推送、列出 repo、clone、改名、切換公開私有、archive、刪除、設定 description/topics、建 release 並上傳附件、打 tag。當使用者提到 Gitea 相關的 push / clone / list / rename / release / archive / delete / tag 等需求時使用。需先在 config.env 設定 Gitea URL、使用者名稱、access token。
---
# gitea-cli
把自架 Gitea 常見操作封裝成 bash 腳本。Token 絕不落地到 `.git/config`
## 使用前準備(只需做一次)
1. 到自己的 Gitea 產生 access token權限至少 `write:repository`
2. 在本 skill 目錄下:
```bash
cp config.example.env config.env
# 編輯 config.env 填入 GITEA_URL / GITEA_USER / GITEA_TOKEN
```
3. `config.env` 已被 `.gitignore` 排除。
## 常見情境 → 對應腳本
`<skill-path>` 代表本 skill 所在路徑clone 下來的位置或 `~/.claude/skills/gitea-cli` symlink
| 要做什麼 | 指令 |
|---|---|
| 建 repo 並把目前專案推上去 | `bash <skill-path>/scripts/publish.sh [repo] [public\|private] [-m "msg"]` |
| 列出自己所有 repo | `bash <skill-path>/scripts/list.sh [--private\|--public] [--format table\|tsv\|name]` |
| clone 某個 repo 到本地 | `bash <skill-path>/scripts/clone.sh <repo> [target-dir]` |
| 改 repo 名稱 | `bash <skill-path>/scripts/rename.sh <old> <new>` |
| 切換 public/private | `bash <skill-path>/scripts/visibility.sh <repo> <public\|private>` |
| 設定 description / topics | `bash <skill-path>/scripts/meta.sh <repo> [--desc "..."] [--topics "a,b,c"]` |
| archive / unarchive | `bash <skill-path>/scripts/archive.sh <repo> [--unarchive] [-y]` |
| 刪除 repo | `bash <skill-path>/scripts/delete.sh <repo> [-y]` |
| 打 tag 並推上去 | `bash <skill-path>/scripts/tag.sh <tag> [-m "msg"]` |
| 建 release含 asset 上傳) | `bash <skill-path>/scripts/release.sh <tag> [--name ...] [--notes ...] [--asset PATH]... [--repo NAME]` |
## 注意
- `config.env` 絕對不要 commit
- `origin` remote 永遠是乾淨 URLtoken 只在 push/clone 當下透過一次性 credential helper 注入
- 破壞性操作(`archive.sh` / `delete.sh`)預設需要互動確認;自動化場景請帶 `-y` / `--yes` 或設 `GITEA_YES=1`
- 401/403 → 檢查 `config.env` 的 token
## 回覆原則
- 執行前先確認 cwd 就是要操作的目標
- 執行後簡述結果URL / branch / 刪除/建立的項目)

13
config.example.env Normal file
View File

@@ -0,0 +1,13 @@
# 複製此檔為 config.env 後填入實際值
# cp config.example.env config.env
# Gitea 站台網址(不要結尾 /
GITEA_URL=http://your-gitea.example.com:3000
# Gitea 使用者名稱
GITEA_USER=your-username
# Gitea personal access token
# 產生位置: <GITEA_URL>/-/user/settings/applications
# 權限勾選: write:repository
GITEA_TOKEN=請貼上你的_access_token

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,259 @@
# gitea-cli 擴充設計
**日期**2026-04-21
**狀態**:待實作
## 背景
`gitea-cli` 目前只有一支 `scripts/publish.sh`,負責在自架 Gitea 建立 repo 並把目前工作目錄推上去。本 spec 定義將它擴充為完整的 Gitea 操作工具集。
## 目標
- 新增三大類功能:**repo 管理**、**取用既有 repo**、**推送進階release / tag / 自訂 commit 訊息)**
- 抽出共用 lib`scripts/lib/common.sh`),新舊腳本都使用
- **完全不破壞** 現有 `publish.sh` 的外部行為:既有呼叫方式仍然有效
- 維持既有安全不變量token 絕不寫進 `.git/config` / remote URL / log
## 非目標(刻意 YAGNI
以下情境**不**在本次範圍,留待真正需要時再加:
- 批次 clone 所有 repo`clone.sh --all`
- Fork / mirror / webhook / collaborator 管理
- 多站台切換 config`GITEA_PROFILE` 等)
- 互動式 UI / 問答精靈Claude 會代問,腳本維持純 CLI
- GitHub / GitLab 相容
## 架構
### 組織方式:多支獨立腳本 + 共用 lib
選擇理由:腳本風格延續既有、每支單一職責、最不破壞現況;相對於單一子命令 CLIshell subcommand 分派需要自己寫會讓單檔肥大。
### 檔案結構
```
gitea-cli/
├── SKILL.md
├── README.md
├── CLAUDE.md
├── config.example.env
├── config.env (gitignored)
├── .gitignore
├── docs/superpowers/specs/ (本 spec 所在)
└── scripts/
├── lib/
│ └── common.sh (所有腳本 source 此檔)
├── publish.sh (重構後 source lib外部行為不變 + 新增 -m)
├── list.sh
├── clone.sh
├── rename.sh
├── visibility.sh
├── archive.sh
├── delete.sh
├── meta.sh
├── release.sh
└── tag.sh
```
### `scripts/lib/common.sh` 介面
**必須放進來**(跨腳本重複且短小):
| Function | 職責 |
|---|---|
| `load_config` | 找 `config.env``source` → 檢查 `GITEA_URL` / `GITEA_USER` / `GITEA_TOKEN` 非空且非預設值;失敗時統一錯誤訊息 |
| `gitea_api <METHOD> <path> [json-body]` | 封裝 curl回傳 HTTP 狀態碼、把 body 寫入 `$GITEA_API_RESP_FILE`;呼叫端自行判斷狀態碼。所有新腳本**只透過這支呼叫 API**,不自己寫 curl |
| `git_push_with_token <branch>` | 封裝一次性 `credential.helper` 推送 |
| `git_clone_with_token <clean-url> <target-dir>` | 同上,用於 cloneclone 完自動把 origin 重設為乾淨 URL |
| `infer_repo_from_cwd` | 從 `git remote get-url origin` 推斷 repo 名;解析不到回非零 |
| `confirm_destructive <prompt>` | 互動 y/N`--yes` flag 或 `GITEA_YES=1` 則直接通過 |
| `confirm_exact_match <expected>` | 要求使用者完整輸入指定字串才通過(給 `delete.sh` 用);同樣支援 `--yes` 略過 |
| `log_info` / `log_warn` / `log_err` | 統一輸出前綴(`▶ / ⚠ / ❌ / ✅ / ✓`**全部寫到 stderr** |
**刻意不放**(留在各腳本):
- 各腳本的 arg 解析(幾行而已,抽共用反而難讀)
- 各 API endpoint 的 JSON body 組字串(放在呼叫處比較直觀)
- `publish.sh` 的三種 git 狀態分支判斷(是 publish 獨有)
### 不變量review 時必守)
1. **Token 絕不落地**token 僅允許出現在 `config.env`、環境變數、`gitea_api` / `git_*_with_token` 函式內部;不得寫進 `.git/config`、remote URL、log、`stderr``stdout`
2. **origin URL 永遠乾淨**`publish.sh``clone.sh` 結束後 origin 都是不含 token 的 URL
3. **`publish.sh` 外部行為**`publish.sh [repo] [public|private]` 原有參數位置與預設值完全不變;`-m "msg"` 是**新增**選項,不影響舊呼叫方式
4. **`set -euo pipefail` + `trap` 清臨時檔**:每支腳本都要
5. **stdout 只有結構化結果stderr 放進度訊息**
## 腳本詳細設計
### 既有腳本(微調)
#### `publish.sh`
**用法**`publish.sh [repo] [public|private] [-m "msg"]`
- 新增 `-m "msg"`:若指定,取代預設 commit 訊息(原本是 `Initial commit` / `Update`
- 重構:`load_config` / `gitea_api` / `git_push_with_token` / `log_*` 改用 lib
- 三種 git 狀態分支保留在腳本內
- **stdout**:最後只印 repo web URL方便 `URL=$(publish.sh)`
- **stderr**:進度訊息
### 新腳本(方向 1repo 管理)
#### `rename.sh`
**用法**`rename.sh <old-name> <new-name>`
- `PATCH /repos/{GITEA_USER}/{old}` body `{"name": "<new>"}`
- 若 cwd 的 `git remote get-url origin` 指向舊 repo順手 `git remote set-url origin <new-clean-url>`
- **stdout**:新 repo web URL
- 不需二次確認(重命名非破壞性)
#### `visibility.sh`
**用法**`visibility.sh <repo> <public|private>`
- `PATCH /repos/{GITEA_USER}/{repo}` body `{"private": true|false}`
- 不需二次確認
#### `archive.sh`
**用法**`archive.sh <repo> [--unarchive] [--yes|-y]`
- `PATCH /repos/{GITEA_USER}/{repo}` body `{"archived": true}``--unarchive``false`
- 互動模式預設需 y/N 確認;`--yes` / `-y` / `GITEA_YES=1` 略過
#### `delete.sh`
**用法**`delete.sh <repo> [--yes|-y]`
- `DELETE /repos/{GITEA_USER}/{repo}`
- 互動模式:用 `confirm_exact_match <repo>` 要求使用者**重新輸入完整 repo 名稱**才動(最高保險)
- `--yes` / `-y` / `GITEA_YES=1` 略過
#### `meta.sh`
**用法**`meta.sh <repo> [--desc "..."] [--topics "a,b,c"]`
- `--desc``PATCH /repos/{owner}/{repo}` body `{"description": "..."}`
- `--topics``PUT /repos/{owner}/{repo}/topics` body `{"topics": ["a","b","c"]}`(覆蓋式,非增量)
- 兩者可同時給;都不給時印用法並非零退出
### 新腳本(方向 2取用既有 repo
#### `list.sh`
**用法**`list.sh [--private|--public] [--format table|tsv|name]`
- `GET /user/repos`(自動翻頁,每頁 50
- `--private` / `--public`:過濾可見性,預設顯示全部
- `--format`
- `table`(預設):對齊欄位 `NAME VISIBILITY DESCRIPTION`
- `tsv``name\tvisibility\tdescription`,給管線 / Claude 用
- `name`:每行一個 repo 名,給 `xargs`
- **stdout**:清單內容
- **stderr**:進度(抓到幾頁、共幾個)
#### `clone.sh`
**用法**`clone.sh <repo> [target-dir]`
- 預設 clone 到 `./<repo>`,可指定 target
- 目標目錄已存在且非空 → 失敗(不覆蓋)
-`git_clone_with_token` 做 clone完成後把 origin 重設為乾淨 URL
- **stdout**clone 到的絕對路徑
### 新腳本(方向 3推送進階
#### `release.sh`
**用法**`release.sh <tag> [--name "..."] [--notes "..."] [--target <branch>] [--draft] [--prerelease] [--asset <path>]...`
- 從 cwd 的 origin 推斷 repo推不到強制要 `--repo <name>`
- **兩段 API**
1. `POST /repos/{owner}/{repo}/releases` body 組合參數 → 取回 `id`
2. 對每個 `--asset``POST /repos/{owner}/{repo}/releases/{id}/assets?name=<basename>``multipart/form-data`
- Tag 處理Gitea release API 的 `tag_name` 若不存在會自動建(指到 `target_commitish`,預設 HEAD。不強制先 `tag.sh`
- **錯誤處理**
- 第 1 步失敗 → 直接結束
- 第 2 步某個 asset 失敗 → 繼續其他 asset最後非零退出並列出失敗的
- **stdout**:第一行 release web URL其餘每行一個 asset 下載 URL
#### `tag.sh`
**用法**`tag.sh <tag> [-m "msg"]`
- 本地 `git tag -a <tag> -m "<msg>"`(預設 msg = `"Tag <tag>"`
- `git_push_with_token` 推這個 tag`git push origin <tag>`
- **定位**:只打 tag 不發 release 的情境;與 `release.sh` 獨立
### Repo 名稱解析原則(跨腳本)
所有需要 repo 名的腳本,若參數省略,呼叫 `infer_repo_from_cwd` 嘗試從 `git remote get-url origin` 推斷。推斷不到就要求使用者帶參數。這樣在 repo 裡直接跑 `rename.sh newname``release.sh v1.0.0` 都能用。
## 錯誤處理與輸出格式
### 退出碼
- `0` 成功
- 非零 失敗(不細分子類;錯誤訊息本身會講清楚原因)
### stdout / stderr 分流
- **stderr**:所有 `▶ / ✓ / ⚠ / ❌ / ✅` 進度訊息與錯誤
- **stdout**:僅最終結構化輸出
- `publish.sh` / `rename.sh` → repo web URL
- `list.sh` → repo 清單
- `clone.sh` → 絕對路徑
- `release.sh` → release URL + 每個 asset URL
- 其他 → 空
**既有 `publish.sh` 變更**:原本進度訊息是 stdout會改到 stderr。在終端機看起來一樣兩者都會顯示但若有人 pipe 其 stdout 會只看到最後的 URL。這是刻意設計不視為 regression。
## 測試策略
無自動化測試。驗收方式:
1. 既有 `publish.sh` 使用情境(三種 git 狀態、public/private、已存在 repo全部手動跑過確認行為不變
2. 每支新腳本在測試用的 Gitea repo 上跑 happy path 一次
3. 破壞性操作(`delete.sh` / `archive.sh`)特別驗證:
- 不帶 `-y` 時會提示
- `delete.sh` 輸入錯 repo 名會被拒
-`-y` 時直接執行
4. Token 不落地驗收:跑完 `publish.sh``grep -r "$GITEA_TOKEN" .git/` 應為空
## 文件同步
### `SKILL.md`
- `description:` 擴寫以涵蓋所有擴充後的觸發情境push / clone / list / rename / release / archive / delete / tag…
- 正文改以「常見情境 → 對應腳本」方式列全部腳本,保留 `config.env` 一次性設定段落
### `README.md`
- 新增「腳本總覽」表格:每支腳本一行用法與一句說明
- 每支新腳本 1-2 段詳細說明 + 範例
- 「安全設計」段落補上「clone 完 origin 也是乾淨 URL」
### `CLAUDE.md`
- 補充「擴展為多腳本」的架構敘述
- 不變量改寫為**所有 push / clone 腳本**都必須守 token 不落地
- 新增「`scripts/lib/common.sh` 的用途與邊界」段落
- 新增「新腳本的檔案骨架」小範本source lib → parse args → 呼叫 lib → 印結果),讓未來加新腳本的人照抄
### `config.example.env`
不變,仍是三個變數。
## 實作順序建議
1. `scripts/lib/common.sh`(所有後續腳本的底)
2. 重構 `publish.sh` 改用 lib新增 `-m`;手動驗證外部行為不變
3. `list.sh` / `clone.sh`(方向 2最常用
4. `rename.sh` / `visibility.sh` / `meta.sh`(方向 1非破壞
5. `archive.sh` / `delete.sh`(方向 1破壞性需測確認機制
6. `tag.sh` / `release.sh`(方向 3
7. 更新 `SKILL.md` / `README.md` / `CLAUDE.md`
8. 整套驗收(上節「測試策略」)

56
scripts/archive.sh Normal file
View File

@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
usage() {
cat >&2 <<'EOF'
Usage: archive.sh <repo> [--unarchive] [-y|--yes]
EOF
exit 1
}
[[ $# -ge 1 ]] || usage
REPO=""
ACTION="archive"
while [[ $# -gt 0 ]]; do
case "$1" in
--unarchive) ACTION="unarchive"; shift ;;
-y|--yes) GITEA_YES=1; shift ;;
-h|--help) usage ;;
-*) log_err "未知參數:$1"; usage ;;
*)
if [[ -z "$REPO" ]]; then REPO="$1"; else log_err "多餘參數:$1"; usage; fi
shift ;;
esac
done
[[ -n "$REPO" ]] || usage
load_config
if [[ "$ACTION" == "archive" ]]; then
confirm_destructive "確定要 archive ${GITEA_USER}/${REPO}"
BODY='{"archived":true}'
MSG="archived"
else
confirm_destructive "確定要 unarchive ${GITEA_USER}/${REPO}"
BODY='{"archived":false}'
MSG="unarchived"
fi
log_info "${ACTION} ${GITEA_USER}/${REPO}"
gitea_api PATCH "/repos/${GITEA_USER}/${REPO}" "$BODY"
case "$HTTP_CODE" in
200|201) log_ok "$MSG" ;;
404) log_err "找不到 repo${GITEA_USER}/${REPO}"; exit 1 ;;
*) log_err "${ACTION} 失敗 (HTTP ${HTTP_CODE})"; { cat "$GITEA_API_RESP_FILE"; echo ""; } >&2; exit 1 ;;
esac
log_done "完成"
echo "${GITEA_URL}/${GITEA_USER}/${REPO}"

48
scripts/clone.sh Normal file
View File

@@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
usage() {
cat >&2 <<'EOF'
Usage: clone.sh <repo> [target-dir]
<repo> GITEA_USER 名下的 repo 名稱
[target-dir] clone 目的地(預設 ./<repo>
EOF
exit 1
}
[[ $# -ge 1 && $# -le 2 ]] || usage
case "$1" in
-h|--help) usage ;;
esac
REPO="$1"
TARGET="${2:-./${REPO}}"
if [[ -e "$TARGET" ]]; then
# 空目錄 OK其他情況拒絕
if [[ -d "$TARGET" ]]; then
if [[ -n "$(ls -A "$TARGET")" ]]; then
log_err "目標目錄已存在且非空:${TARGET}"
exit 1
fi
else
log_err "目標路徑已存在且不是目錄:${TARGET}"
exit 1
fi
fi
load_config
CLEAN_URL="${GITEA_URL}/${GITEA_USER}/${REPO}.git"
log_info "clone ${GITEA_USER}/${REPO}${TARGET}"
git_clone_with_token "$CLEAN_URL" "$TARGET"
ABS="$(cd "$TARGET" && pwd)"
log_done "完成"
echo "$ABS"

48
scripts/delete.sh Normal file
View File

@@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
usage() {
cat >&2 <<'EOF'
Usage: delete.sh <repo> [-y|--yes]
互動模式:會要求重新輸入完整 repo 名稱才執行。
-y / --yes 或 GITEA_YES=1直接執行不需輸入。
EOF
exit 1
}
[[ $# -ge 1 ]] || usage
REPO=""
while [[ $# -gt 0 ]]; do
case "$1" in
-y|--yes) GITEA_YES=1; shift ;;
-h|--help) usage ;;
-*) log_err "未知參數:$1"; usage ;;
*)
if [[ -z "$REPO" ]]; then REPO="$1"; else log_err "多餘參數:$1"; usage; fi
shift ;;
esac
done
[[ -n "$REPO" ]] || usage
load_config
log_warn "即將刪除 ${GITEA_USER}/${REPO}(此操作不可復原)"
confirm_exact_match "$REPO"
log_info "刪除 ${GITEA_USER}/${REPO}"
gitea_api DELETE "/repos/${GITEA_USER}/${REPO}"
case "$HTTP_CODE" in
204) log_ok "已刪除" ;;
404) log_err "找不到 repo${GITEA_USER}/${REPO}"; exit 1 ;;
*) log_err "刪除失敗 (HTTP ${HTTP_CODE})"; { cat "$GITEA_API_RESP_FILE"; echo ""; } >&2; exit 1 ;;
esac
log_done "完成"

193
scripts/lib/common.sh Normal file
View File

@@ -0,0 +1,193 @@
#!/usr/bin/env bash
# gitea-cli 共用 lib。
# 使用方式(呼叫端):
# SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# source "${SCRIPT_DIR}/lib/common.sh"
# load_config
#
# 重要:所有 API 呼叫走 gitea_api所有 push/clone 走 git_push_with_token / git_clone_with_token。
# token 絕不允許出現在 .git/config、remote URL、log 中。
set -euo pipefail
_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_SKILL_DIR="$(cd "${_LIB_DIR}/../.." && pwd)"
CONFIG_FILE="${_SKILL_DIR}/config.env"
GITEA_API_RESP_FILE=""
_cleanup_common() {
if [[ -n "${GITEA_API_RESP_FILE:-}" && -f "${GITEA_API_RESP_FILE}" ]]; then
rm -f "${GITEA_API_RESP_FILE}"
fi
}
trap _cleanup_common EXIT
# === logging全部寫 stderrstdout 留給結構化輸出)===
log_info() { printf '▶ %s\n' "$*" >&2; }
log_ok() { printf '✓ %s\n' "$*" >&2; }
log_warn() { printf '⚠ %s\n' "$*" >&2; }
log_err() { printf '❌ %s\n' "$*" >&2; }
log_done() { printf '✅ %s\n' "$*" >&2; }
# === 必要指令檢查 ===
require_cmd() {
local missing=()
for c in "$@"; do
command -v "$c" >/dev/null 2>&1 || missing+=("$c")
done
if [[ ${#missing[@]} -gt 0 ]]; then
log_err "缺少指令:${missing[*]}"
exit 1
fi
}
# === config 載入 ===
load_config() {
require_cmd curl git
if [[ ! -f "$CONFIG_FILE" ]]; then
log_err "找不到設定檔:${CONFIG_FILE}"
{
echo ""
echo "請先建立:"
echo " cp ${_SKILL_DIR}/config.example.env ${CONFIG_FILE}"
echo ""
echo "然後編輯 ${CONFIG_FILE} 填入 GITEA_URL / GITEA_USER / GITEA_TOKEN"
} >&2
exit 1
fi
# shellcheck disable=SC1090
source "$CONFIG_FILE"
: "${GITEA_URL:?GITEA_URL 未設定(請編輯 ${CONFIG_FILE}}"
: "${GITEA_USER:?GITEA_USER 未設定(請編輯 ${CONFIG_FILE}}"
: "${GITEA_TOKEN:?GITEA_TOKEN 未設定(請編輯 ${CONFIG_FILE}}"
if [[ "$GITEA_TOKEN" == "請貼上你的_access_token" ]]; then
log_err "GITEA_TOKEN 仍是預設值,請編輯 ${CONFIG_FILE}"
exit 1
fi
if [[ "$GITEA_URL" == "http://your-gitea.example.com:3000" ]]; then
log_err "GITEA_URL 仍是預設值,請編輯 ${CONFIG_FILE}"
exit 1
fi
if [[ "$GITEA_USER" == "your-username" ]]; then
log_err "GITEA_USER 仍是預設值,請編輯 ${CONFIG_FILE}"
exit 1
fi
GITEA_URL="${GITEA_URL%/}"
export GITEA_URL GITEA_USER GITEA_TOKEN
}
# === Gitea API 呼叫 ===
# gitea_api <METHOD> <path-from-/api/v1> [json-body]
# 回傳後讀:$HTTP_CODE狀態碼字串、$GITEA_API_RESP_FILEbody 檔路徑)
gitea_api() {
# 清掉上一次的 tmpfile防止多次呼叫時洩漏
if [[ -n "${GITEA_API_RESP_FILE:-}" && -f "${GITEA_API_RESP_FILE}" ]]; then
rm -f "${GITEA_API_RESP_FILE}"
fi
local method="$1"
local path="$2"
local body="${3:-}"
local url="${GITEA_URL}/api/v1${path}"
GITEA_API_RESP_FILE="$(mktemp)"
local -a curl_args=(
-sS
-o "$GITEA_API_RESP_FILE"
-w '%{http_code}'
-X "$method"
-H "Authorization: token ${GITEA_TOKEN}"
)
if [[ -n "$body" ]]; then
curl_args+=(-H "Content-Type: application/json" -d "$body")
fi
HTTP_CODE="$(curl "${curl_args[@]}" "$url")"
export HTTP_CODE GITEA_API_RESP_FILE
}
# 一次性帶 token 的 git push / clone
# 注意token 透過 -c 參數傳入,在 git 子程序存活期間短暫出現於 process 列表
# /proc/PID/cmdline。這是此模式固有取捨不會持久化到 .git/config / remote URL / log。
_git_with_token() {
git \
-c 'credential.helper=' \
-c "credential.helper=!f() { echo username=${GITEA_USER}; echo password=${GITEA_TOKEN}; }; f" \
"$@"
}
git_push_with_token() {
local branch="$1"
_git_with_token push -u origin "$branch"
}
git_push_tag_with_token() {
local tag="$1"
_git_with_token push origin "$tag"
}
git_clone_with_token() {
local clean_url="$1"
local target_dir="$2"
_git_with_token clone "$clean_url" "$target_dir"
# 再確保 origin 是乾淨 URLclone 本來就是用 clean_url這裡是防呆
git -C "$target_dir" remote set-url origin "$clean_url"
}
# 從 cwd origin 推斷 repo 名稱
# 成功時 echo "owner/repo"timmy/my-project並回傳 0失敗回傳非 0
# 呼叫端若只需要 repo 名稱repo_name="${result##*/}"
# 只支援 http(s):// 形式 URLssh (git@) 不支援(刻意 YAGNI
infer_repo_from_cwd() {
local url
if ! url="$(git remote get-url origin 2>/dev/null)"; then
return 1
fi
# 支援 http(s)://host[:port]/owner/repo[.git]
local path="${url#*://}"
path="${path#*/}"
path="${path%.git}"
[[ -z "$path" ]] && return 1
echo "$path"
}
# === 確認提示 ===
# confirm_destructive <prompt>
# 若 GITEA_YES=1 則直接通過;否則 y/N 互動
confirm_destructive() {
local prompt="$1"
if [[ "${GITEA_YES:-0}" == "1" ]]; then
return 0
fi
if [[ ! -r /dev/tty ]]; then
log_err "需要確認但無 TTY請加 -y / --yes 或設定 GITEA_YES=1"
exit 1
fi
local ans
printf '%s [y/N] ' "$prompt" >&2
read -r ans < /dev/tty
case "$ans" in
y|Y|yes|YES) return 0 ;;
*) log_err "取消"; exit 1 ;;
esac
}
# confirm_exact_match <expected>
# 要求使用者重新輸入完整字串才通過GITEA_YES=1 可略過
confirm_exact_match() {
local expected="$1"
if [[ "${GITEA_YES:-0}" == "1" ]]; then
return 0
fi
if [[ ! -r /dev/tty ]]; then
log_err "需要確認但無 TTY請加 -y / --yes 或設定 GITEA_YES=1"
exit 1
fi
local ans
printf '請完整輸入 "%s" 以確認:' "$expected" >&2
read -r ans < /dev/tty
if [[ "$ans" == "$expected" ]]; then
return 0
fi
log_err "輸入不符合,取消"
exit 1
}

82
scripts/list.sh Executable file
View File

@@ -0,0 +1,82 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
FILTER=""
FORMAT="table"
usage() {
cat >&2 <<'EOF'
Usage: list.sh [--private|--public] [--format table|tsv|name]
--private 只列出 private repo
--public 只列出 public repo
--format FORMAT table (default) | tsv | name
EOF
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--private) FILTER="private"; shift ;;
--public) FILTER="public"; shift ;;
--format)
[[ $# -ge 2 ]] || { log_err "--format 需要一個值"; exit 1; }
FORMAT="$2"; shift 2 ;;
-h|--help) usage ;;
*) log_err "未知參數:$1"; usage ;;
esac
done
case "$FORMAT" in
table|tsv|name) ;;
*) log_err "--format 只能是 table / tsv / name"; exit 1 ;;
esac
load_config
require_cmd jq column
PAGE=1
LIMIT=50
ALL_ITEMS="$(mktemp)"
trap 'rm -f "$ALL_ITEMS"; _cleanup_common' EXIT
while :; do
gitea_api GET "/user/repos?limit=${LIMIT}&page=${PAGE}"
if [[ "$HTTP_CODE" != "200" ]]; then
log_err "GET /user/repos 失敗 (HTTP ${HTTP_CODE})"
cat "$GITEA_API_RESP_FILE" >&2
exit 1
fi
jq -c '.[]' < "$GITEA_API_RESP_FILE" >> "$ALL_ITEMS"
COUNT="$(jq 'length' < "$GITEA_API_RESP_FILE")"
[[ "$COUNT" -lt "$LIMIT" ]] && break
PAGE=$((PAGE + 1))
done
filter_jq="."
case "$FILTER" in
private) filter_jq='select(.private == true)' ;;
public) filter_jq='select(.private == false)' ;;
esac
TOTAL="$(jq -s "map($filter_jq) | length" < "$ALL_ITEMS")"
log_info "抓到 ${TOTAL} 個 repo"
case "$FORMAT" in
name)
jq -r "$filter_jq | .name" < "$ALL_ITEMS"
;;
tsv)
jq -r "$filter_jq | [.name, (if .private then \"private\" else \"public\" end), (.description // \"\")] | @tsv" < "$ALL_ITEMS"
;;
table)
{
printf 'NAME\tVISIBILITY\tDESCRIPTION\n'
jq -r "$filter_jq | [.name, (if .private then \"private\" else \"public\" end), (.description // \"\")] | @tsv" < "$ALL_ITEMS"
} | column -t -s $'\t'
;;
esac

72
scripts/meta.sh Normal file
View File

@@ -0,0 +1,72 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
usage() {
cat >&2 <<'EOF'
Usage: meta.sh <repo> [--desc "description"] [--topics "a,b,c"]
--desc 設定 repo description
--topics 覆蓋式設定 topic list逗號分隔
EOF
exit 1
}
[[ $# -ge 1 ]] || usage
REPO=""
DESC=""
HAS_DESC=0
TOPICS_CSV=""
HAS_TOPICS=0
while [[ $# -gt 0 ]]; do
case "$1" in
--desc)
[[ $# -ge 2 ]] || { log_err "--desc 需要一個值"; exit 1; }
DESC="$2"; HAS_DESC=1; shift 2 ;;
--topics)
[[ $# -ge 2 ]] || { log_err "--topics 需要一個值"; exit 1; }
TOPICS_CSV="$2"; HAS_TOPICS=1; shift 2 ;;
-h|--help) usage ;;
-*) log_err "未知參數:$1"; usage ;;
*)
if [[ -z "$REPO" ]]; then REPO="$1"; else log_err "多餘參數:$1"; usage; fi
shift ;;
esac
done
[[ -n "$REPO" ]] || usage
[[ $HAS_DESC -eq 1 || $HAS_TOPICS -eq 1 ]] || { log_err "必須至少提供 --desc 或 --topics"; usage; }
load_config
require_cmd jq
if [[ $HAS_DESC -eq 1 ]]; then
ESCAPED_DESC="$(printf '%s' "$DESC" | jq -Rs .)"
log_info "更新 description"
gitea_api PATCH "/repos/${GITEA_USER}/${REPO}" "{\"description\":${ESCAPED_DESC}}"
case "$HTTP_CODE" in
200|201) log_ok "description 已更新" ;;
404) log_err "找不到 repo${GITEA_USER}/${REPO}"; exit 1 ;;
*) log_err "更新 description 失敗 (HTTP ${HTTP_CODE})"; { cat "$GITEA_API_RESP_FILE"; echo ""; } >&2; exit 1 ;;
esac
fi
if [[ $HAS_TOPICS -eq 1 ]]; then
TOPICS_JSON="$(printf '%s' "$TOPICS_CSV" | jq -R 'split(",") | map(select(length > 0))')"
log_info "覆蓋 topics${TOPICS_JSON}"
gitea_api PUT "/repos/${GITEA_USER}/${REPO}/topics" "{\"topics\":${TOPICS_JSON}}"
case "$HTTP_CODE" in
200|204) log_ok "topics 已更新" ;;
404) log_err "找不到 repo${GITEA_USER}/${REPO}"; exit 1 ;;
422) log_err "topics 格式錯誤"; { cat "$GITEA_API_RESP_FILE"; echo ""; } >&2; exit 1 ;;
*) log_err "更新 topics 失敗 (HTTP ${HTTP_CODE})"; { cat "$GITEA_API_RESP_FILE"; echo ""; } >&2; exit 1 ;;
esac
fi
log_done "完成"
echo "${GITEA_URL}/${GITEA_USER}/${REPO}"

123
scripts/publish.sh Executable file
View File

@@ -0,0 +1,123 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
REPO_NAME=""
VISIBILITY="public"
COMMIT_MSG=""
while [[ $# -gt 0 ]]; do
case "$1" in
-m)
[[ $# -ge 2 ]] || { log_err "-m 需要一個訊息參數"; exit 1; }
COMMIT_MSG="$2"
shift 2
;;
public|private)
VISIBILITY="$1"
shift
;;
-h|--help)
cat >&2 <<'EOF'
Usage: publish.sh [repo] [public|private] [-m "commit message"]
EOF
exit 0
;;
-*)
log_err "未知參數:$1"
exit 1
;;
*)
if [[ -z "$REPO_NAME" ]]; then
REPO_NAME="$1"
elif [[ "$VISIBILITY" == "public" ]]; then
VISIBILITY="$1"
else
log_err "多餘參數:$1"
exit 1
fi
shift
;;
esac
done
REPO_NAME="${REPO_NAME:-$(basename "$PWD")}"
case "$VISIBILITY" in
public) PRIVATE=false ;;
private) PRIVATE=true ;;
*)
log_err "可見性只能是 public 或 private收到${VISIBILITY}"
exit 1
;;
esac
load_config
CLEAN_URL="${GITEA_URL}/${GITEA_USER}/${REPO_NAME}.git"
WEB_URL="${GITEA_URL}/${GITEA_USER}/${REPO_NAME}"
log_info "建立 Gitea repo${GITEA_USER}/${REPO_NAME} (${VISIBILITY})"
gitea_api POST "/user/repos" \
"{\"name\":\"${REPO_NAME}\",\"private\":${PRIVATE},\"auto_init\":false,\"default_branch\":\"main\"}"
case "$HTTP_CODE" in
201) log_ok "repo 已建立" ;;
409) log_warn "repo 已存在,略過建立步驟" ;;
401|403)
log_err "Gitea 授權失敗 (HTTP ${HTTP_CODE})"
{
echo "請確認 ${CONFIG_FILE} 的 GITEA_TOKEN 是否正確、權限是否包含 write:repository"
cat "$GITEA_API_RESP_FILE"
echo ""
} >&2
exit 1
;;
*)
log_err "建立 repo 失敗 (HTTP ${HTTP_CODE})"
{ cat "$GITEA_API_RESP_FILE"; echo ""; } >&2
exit 1
;;
esac
if [[ ! -d .git ]]; then
log_info "git init"
git init -b main >/dev/null
fi
if ! git rev-parse --verify HEAD >/dev/null 2>&1; then
git add -A
if git diff --cached --quiet; then
log_info "工作目錄沒有檔案,建立空的 initial commit"
git commit --allow-empty -m "${COMMIT_MSG:-Initial commit}" >/dev/null
else
log_info "建立 initial commit"
git commit -m "${COMMIT_MSG:-Initial commit}" >/dev/null
fi
else
git add -A
if ! git diff --cached --quiet; then
log_info "提交未提交的變更"
git commit -m "${COMMIT_MSG:-Update}" >/dev/null
fi
fi
BRANCH="$(git rev-parse --abbrev-ref HEAD)"
if git remote get-url origin >/dev/null 2>&1; then
git remote set-url origin "$CLEAN_URL"
else
git remote add origin "$CLEAN_URL"
fi
log_info "推送到 origin (${CLEAN_URL}) branch=${BRANCH}"
git_push_with_token "$BRANCH"
log_done "完成"
echo " Branch ${BRANCH}" >&2
# stdout最終結構化輸出
echo "$WEB_URL"

140
scripts/release.sh Executable file
View File

@@ -0,0 +1,140 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
usage() {
cat >&2 <<'EOF'
Usage: release.sh <tag> [--name NAME] [--notes TEXT] [--target BRANCH]
[--draft] [--prerelease] [--asset PATH]... [--repo NAME]
<tag> tag_name若 tag 不存在 Gitea 會在 target 自動建
--name release 標題(預設同 tag
--notes release 說明
--target target_commitish預設 HEAD 所在分支
--draft 建成 draft
--prerelease 標記 prerelease
--asset PATH 要上傳的附件(可重複)
--repo NAME 指定 repo省略則從 cwd origin 推斷
EOF
exit 1
}
[[ $# -ge 1 ]] || usage
TAG=""
NAME=""
NOTES=""
TARGET=""
DRAFT=false
PRERELEASE=false
ASSETS=()
REPO_OVERRIDE=""
while [[ $# -gt 0 ]]; do
case "$1" in
--name) [[ $# -ge 2 ]] || { log_err "--name 需要值"; exit 1; }; NAME="$2"; shift 2 ;;
--notes) [[ $# -ge 2 ]] || { log_err "--notes 需要值"; exit 1; }; NOTES="$2"; shift 2 ;;
--target) [[ $# -ge 2 ]] || { log_err "--target 需要值"; exit 1; }; TARGET="$2"; shift 2 ;;
--draft) DRAFT=true; shift ;;
--prerelease) PRERELEASE=true; shift ;;
--asset) [[ $# -ge 2 ]] || { log_err "--asset 需要值"; exit 1; }; ASSETS+=("$2"); shift 2 ;;
--repo) [[ $# -ge 2 ]] || { log_err "--repo 需要值"; exit 1; }; REPO_OVERRIDE="$2"; shift 2 ;;
-h|--help) usage ;;
-*) log_err "未知參數:$1"; usage ;;
*)
if [[ -z "$TAG" ]]; then TAG="$1"; else log_err "多餘參數:$1"; usage; fi
shift ;;
esac
done
[[ -n "$TAG" ]] || usage
NAME="${NAME:-$TAG}"
load_config
require_cmd jq
if [[ -n "$REPO_OVERRIDE" ]]; then
OWNER_REPO="${GITEA_USER}/${REPO_OVERRIDE}"
else
if ! OWNER_REPO="$(infer_repo_from_cwd)"; then
log_err "無法從 cwd 推斷 repo請加 --repo"
exit 1
fi
fi
if [[ -z "$TARGET" ]]; then
if git rev-parse --git-dir >/dev/null 2>&1; then
TARGET="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
fi
fi
# 驗證 asset 路徑
for a in "${ASSETS[@]+"${ASSETS[@]}"}"; do
if [[ ! -f "$a" ]]; then
log_err "找不到 asset 檔案:$a"
exit 1
fi
done
# 組 release body
BODY="$(jq -n \
--arg tag "$TAG" \
--arg name "$NAME" \
--arg body "$NOTES" \
--arg target "$TARGET" \
--argjson draft "$DRAFT" \
--argjson prerelease "$PRERELEASE" \
'{tag_name:$tag, name:$name, body:$body, draft:$draft, prerelease:$prerelease}
+ (if $target == "" then {} else {target_commitish:$target} end)')"
log_info "建立 release ${OWNER_REPO} / ${TAG}"
gitea_api POST "/repos/${OWNER_REPO}/releases" "$BODY"
case "$HTTP_CODE" in
201) log_ok "release 已建立" ;;
404) log_err "找不到 repo${OWNER_REPO}"; exit 1 ;;
*) log_err "建立 release 失敗 (HTTP ${HTTP_CODE})"; { cat "$GITEA_API_RESP_FILE"; echo ""; } >&2; exit 1 ;;
esac
RELEASE_ID="$(jq -r '.id' < "$GITEA_API_RESP_FILE")"
RELEASE_URL="$(jq -r '.html_url' < "$GITEA_API_RESP_FILE")"
# stdout 先印 release URL
echo "$RELEASE_URL"
# 上傳 asset
FAILED=()
for a in "${ASSETS[@]+"${ASSETS[@]}"}"; do
local_name="$(basename "$a")"
log_info "上傳 asset${local_name}"
UP_RESP="$(mktemp)"
UP_CODE="$(curl -sS -o "$UP_RESP" -w '%{http_code}' \
-X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@${a};filename=${local_name}" \
"${GITEA_URL}/api/v1/repos/${OWNER_REPO}/releases/${RELEASE_ID}/assets?name=${local_name}")"
case "$UP_CODE" in
201)
ASSET_URL="$(jq -r '.browser_download_url' < "$UP_RESP")"
log_ok "$local_name"
echo "$ASSET_URL"
;;
*)
log_err "上傳 ${local_name} 失敗 (HTTP ${UP_CODE})"
cat "$UP_RESP" >&2
echo "" >&2
FAILED+=("$local_name")
;;
esac
rm -f "$UP_RESP"
done
if [[ ${#FAILED[@]} -gt 0 ]]; then
log_err "部分 asset 上傳失敗:${FAILED[*]}"
exit 1
fi
log_done "完成"

47
scripts/rename.sh Normal file
View File

@@ -0,0 +1,47 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
usage() {
cat >&2 <<'EOF'
Usage: rename.sh <old-name> <new-name>
EOF
exit 1
}
[[ $# -eq 2 ]] || usage
OLD="$1"
NEW="$2"
load_config
log_info "改名 ${GITEA_USER}/${OLD}${GITEA_USER}/${NEW}"
gitea_api PATCH "/repos/${GITEA_USER}/${OLD}" "{\"name\":\"${NEW}\"}"
case "$HTTP_CODE" in
200|201) log_ok "改名完成" ;;
404) log_err "找不到 repo${GITEA_USER}/${OLD}"; exit 1 ;;
422) log_err "新名稱可能已被使用或無效"; cat "$GITEA_API_RESP_FILE" >&2; echo "" >&2; exit 1 ;;
*)
log_err "改名失敗 (HTTP ${HTTP_CODE})"
{ cat "$GITEA_API_RESP_FILE"; echo ""; } >&2
exit 1
;;
esac
# 若 cwd 的 origin 指到舊 repo自動更新
if CURR="$(infer_repo_from_cwd 2>/dev/null)"; then
if [[ "$CURR" == "${GITEA_USER}/${OLD}" ]]; then
NEW_CLEAN_URL="${GITEA_URL}/${GITEA_USER}/${NEW}.git"
git remote set-url origin "$NEW_CLEAN_URL"
log_ok "cwd 的 origin 已更新為 ${NEW_CLEAN_URL}"
fi
fi
NEW_WEB_URL="${GITEA_URL}/${GITEA_USER}/${NEW}"
log_done "完成"
echo "$NEW_WEB_URL"

56
scripts/tag.sh Normal file
View File

@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
usage() {
cat >&2 <<'EOF'
Usage: tag.sh <tag> [-m "message"]
建立 annotated tag 並推到 origin。
需要在 cwd 已有 git repo 且 origin 已指到 Gitea。
EOF
exit 1
}
[[ $# -ge 1 ]] || usage
TAG=""
MSG=""
while [[ $# -gt 0 ]]; do
case "$1" in
-m) [[ $# -ge 2 ]] || { log_err "-m 需要一個值"; exit 1; }; MSG="$2"; shift 2 ;;
-h|--help) usage ;;
-*) log_err "未知參數:$1"; usage ;;
*)
if [[ -z "$TAG" ]]; then TAG="$1"; else log_err "多餘參數:$1"; usage; fi
shift ;;
esac
done
[[ -n "$TAG" ]] || usage
MSG="${MSG:-Tag $TAG}"
load_config
if ! git rev-parse --git-dir >/dev/null 2>&1; then
log_err "cwd 不是 git repo"
exit 1
fi
if git rev-parse "$TAG" >/dev/null 2>&1; then
log_err "tag 已存在:${TAG}"
exit 1
fi
log_info "建立 tag ${TAG}"
git tag -a "$TAG" -m "$MSG"
log_info "推送 tag 到 origin"
git_push_tag_with_token "$TAG"
log_done "完成"
echo "$TAG"

42
scripts/visibility.sh Normal file
View File

@@ -0,0 +1,42 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
usage() {
cat >&2 <<'EOF'
Usage: visibility.sh <repo> <public|private>
EOF
exit 1
}
[[ $# -eq 2 ]] || usage
REPO="$1"
VIS="$2"
case "$VIS" in
public) PRIVATE=false ;;
private) PRIVATE=true ;;
*) log_err "可見性只能是 public 或 private"; exit 1 ;;
esac
load_config
log_info "切換 ${GITEA_USER}/${REPO}${VIS}"
gitea_api PATCH "/repos/${GITEA_USER}/${REPO}" "{\"private\":${PRIVATE}}"
case "$HTTP_CODE" in
200|201) log_ok "已更新" ;;
404) log_err "找不到 repo${GITEA_USER}/${REPO}"; exit 1 ;;
*)
log_err "更新失敗 (HTTP ${HTTP_CODE})"
{ cat "$GITEA_API_RESP_FILE"; echo ""; } >&2
exit 1
;;
esac
log_done "完成"
echo "${GITEA_URL}/${GITEA_USER}/${REPO}"