diff --git a/README.md b/README.md index 1bf079b..8e588b7 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ - [與 Codex 的對照](#與-codex-的對照) - [HTTP API](#http-api) - [打包成 exe](#打包成-exe) +- [發佈到 Gitea Releases](#發佈到-gitea-releases) - [疑難排解](#疑難排解) - [移除](#移除) @@ -229,7 +230,7 @@ Claude Code ──hook(stdin JSON)──▶ hook/claude-pet-hook.js ──PO | `lib/hooks-installer.js` | 安全地合併 / 移除 `~/.claude/settings.json` 的 hooks(保留其他設定與其他 hooks,先備份) | | `lib/autostart.js` | `HKCU\Software\Microsoft\Windows\CurrentVersion\Run\ClaudePet` 登錄值;開發時指向 `electron.exe + 專案路徑`,打包後直接指向那顆 exe | | `lib/paths.js` | 開發/安裝版/portable 三種情境的路徑解析(`app.asar` → `app.asar.unpacked`、是否打包、portable 的真實 exe 位置) | -| `scripts/*.js` | 上述兩者的 CLI 包裝 | +| `scripts/*.js` | 上述兩者的 CLI 包裝,以及 `release.js`(發佈到 Gitea Releases) | | `pets/` | 內附寵物 | | `docs/` | README 用圖 | | `build/icon.ico` | exe 與安裝檔的圖示,取自小念 spritesheet 第 0 列第 6 欄(中立正面)的頭肩方形裁切 | @@ -370,6 +371,41 @@ NSIS 的 portable 外殼**每次啟動**都會把整包解壓到 `%TEMP%\ClaudeP 換圖示就換掉 `build/icon.ico`(要含 256×256)。目前這顆是從 `pets/xiao-nian/spritesheet.webp` 第 0 列第 6 欄(中立正面)取頭肩方形裁切產生的。 +## 發佈到 Gitea Releases + +```powershell +$env:GITEA_TOKEN = "" +npm run release -- --dry-run # 只檢查:產物、附件限制、release 是否已存在,不做任何寫入 +npm run release # 實際發佈 +``` + +會建立 tag `v<版本>` 的 release(說明自動取自上一個 tag 之後的 commit 標題), +並上傳 `dist\` 裡的 `.exe` 與 `.zip`。同名附件會先刪再傳,所以可以重跑。 +`.blockmap` 不上傳——那是 electron-updater 的差分更新才需要,本專案沒有用到。 + +**前置一:伺服器設定。** Gitea 預設的附件上限是 100 MB,而且 `ALLOWED_TYPES` 白名單沒有 `.exe`, +三個產物全部會被擋下。在 `app.ini` 調整後重啟 Gitea: + +```ini +[attachment] +MAX_SIZE = 300 +ALLOWED_TYPES = */* +``` + +腳本會先讀 `/api/v1/settings/attachment` 做預檢,設定不足時會直接列出哪個檔案卡在哪一條、 +不會傳到一半才失敗。 + +**前置二:token。** 到 Gitea 的「設定 → 應用程式 → 產生新的權杖」, +勾選 `write:repository`(只有套件庫權限的 token 不行,repo API 會回 403)。 +放環境變數就好,不要寫進檔案。 + +**其他環境變數** + +| 變數 | 用途 | +|---|---| +| `GITEA_TOKEN` | 必填,需 `write:repository` | +| `GITEA_URL` | 網頁網址與 remote 主機不同時覆寫(預設 `https://`)。SSH 走 8022 埠不影響,腳本只取主機名 | + ## 疑難排解 ### Electron 裝完沒有 `electron.exe` diff --git a/package.json b/package.json index 0cc9594..b87f5cb 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "predist": "npm version patch --no-git-tag-version", "dist": "electron-builder --win", "pack": "electron-builder --win --dir", + "release": "node scripts/release.js", "predist:portable": "npm version patch --no-git-tag-version", "dist:portable": "electron-builder --win portable" }, diff --git a/scripts/release.js b/scripts/release.js new file mode 100644 index 0000000..5f61a21 --- /dev/null +++ b/scripts/release.js @@ -0,0 +1,228 @@ +"use strict"; +// 把 dist/ 的產物發佈到 Gitea 的 Releases。 +// +// set GITEA_TOKEN= (PowerShell:$env:GITEA_TOKEN="") +// npm run release 實際發佈 +// npm run release -- --dry-run 只檢查,不做任何寫入 +// +// token 需要 write:repository 權限。owner/repo 由 git remote 推導, +// 網址預設為 https://;SSH 埠與網頁埠不同時用 GITEA_URL 覆寫。 + +const fs = require("node:fs"); +const http = require("node:http"); +const https = require("node:https"); +const path = require("node:path"); +const { execFileSync } = require("node:child_process"); + +const APP_DIR = path.resolve(__dirname, ".."); +const DIST_DIR = path.join(APP_DIR, "dist"); +const DRY_RUN = process.argv.includes("--dry-run"); +const TOKEN = process.env.GITEA_TOKEN || ""; + +const pkg = JSON.parse(fs.readFileSync(path.join(APP_DIR, "package.json"), "utf8")); +const TAG = `v${pkg.version}`; + +// 要上傳的產物;.blockmap 只有 electron-updater 的差分更新才需要,目前沒用到 +const ASSET_PATTERNS = [/\.exe$/i, /\.zip$/i]; + +function git(...args) { + // stderr 丟掉:releaseNotes 會刻意去 describe 一個可能不存在的舊 tag, + // 讓 git 的 fatal 訊息印出來會被誤認成發佈失敗 + return execFileSync("git", args, { cwd: APP_DIR, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); +} + +function remoteInfo() { + const url = git("remote", "get-url", "origin"); + const m = url.match(/^(?:ssh:\/\/(?:[^@]+@)?|(?:https?:\/\/)(?:[^@]+@)?|(?:[^@]+@))([^:/]+)(?::\d+)?[:/](.+?)\/([^/]+?)(?:\.git)?$/); + if (!m) throw new Error(`看不懂 remote 網址:${url}`); + return { host: m[1], owner: m[2], repo: m[3] }; +} + +// stream 模式:先送 head,再串流檔案,最後以 tail 收尾。 +// 產物上百 MB,不能整份讀進記憶體。 +function request(method, url, { headers = {}, body = null, head = null, tail = null, stream = null, size = 0 } = {}) { + return new Promise((resolve, reject) => { + const u = new URL(url); + const lib = u.protocol === "https:" ? https : http; + const req = lib.request(u, { method, headers }, (res) => { + let text = ""; + res.setEncoding("utf8"); + res.on("data", (c) => { text += c; }); + res.on("end", () => { + let json = null; + try { json = text ? JSON.parse(text) : null; } catch { /* 非 JSON 就留 null */ } + resolve({ status: res.statusCode, json, text }); + }); + }); + req.on("error", reject); + if (!stream) { + req.end(body); + return; + } + req.write(head); + let sent = 0; + let lastPct = -1; + stream.on("data", (chunk) => { + sent += chunk.length; + const pct = Math.floor((sent / size) * 100); + if (pct >= lastPct + 10) { lastPct = pct; process.stdout.write(`\r 上傳中 ${pct}% `); } + }); + stream.on("error", reject); + stream.pipe(req, { end: false }); + stream.on("end", () => req.end(tail)); + }); +} + +function apiUrl(base, owner, repo, suffix) { + return `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${suffix}`; +} + +async function api(method, url, payload) { + const headers = { Accept: "application/json" }; + if (TOKEN) headers.Authorization = `token ${TOKEN}`; + let body = null; + if (payload !== undefined) { + body = Buffer.from(JSON.stringify(payload), "utf8"); + headers["Content-Type"] = "application/json"; + headers["Content-Length"] = body.length; + } + return request(method, url, { headers, body }); +} + +async function uploadAsset(base, owner, repo, releaseId, filePath) { + const name = path.basename(filePath); + const size = fs.statSync(filePath).size; + const boundary = `----claudepet${Date.now().toString(16)}`; + const head = Buffer.from( + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="attachment"; filename="${name}"\r\n` + + "Content-Type: application/octet-stream\r\n\r\n", + "utf8", + ); + const tail = Buffer.from(`\r\n--${boundary}--\r\n`, "utf8"); + const url = `${apiUrl(base, owner, repo, `releases/${releaseId}/assets`)}?name=${encodeURIComponent(name)}`; + const res = await request("POST", url, { + headers: { + Authorization: `token ${TOKEN}`, + Accept: "application/json", + "Content-Type": `multipart/form-data; boundary=${boundary}`, + "Content-Length": head.length + size + tail.length, + }, + head, + tail, + stream: fs.createReadStream(filePath), + size, + }); + process.stdout.write("\r"); + return res; +} + +function releaseNotes() { + let range = ""; + try { + const prev = git("describe", "--tags", "--abbrev=0", `${TAG}^`); + range = `${prev}..HEAD`; + } catch { + try { git("rev-parse", "HEAD"); range = "HEAD"; } catch { range = ""; } + } + try { + const log = range === "HEAD" + ? git("log", "-n", "30", "--pretty=- %s") // 沒有舊 tag(第一次發佈)就取最近 30 筆 + : git("log", range, "--pretty=- %s"); + return log || `Claude Pet ${TAG}`; + } catch { + return `Claude Pet ${TAG}`; + } +} + +async function main() { + const { host, owner, repo } = remoteInfo(); + const base = (process.env.GITEA_URL || `https://${host}`).replace(/\/+$/, ""); + + console.log(`Gitea : ${base}`); + console.log(`Repo : ${owner}/${repo}`); + console.log(`Tag : ${TAG}`); + + if (!fs.existsSync(DIST_DIR)) throw new Error(`找不到 ${DIST_DIR},先執行 npm run dist`); + const files = fs.readdirSync(DIST_DIR) + .filter((f) => ASSET_PATTERNS.some((re) => re.test(f))) + .map((f) => path.join(DIST_DIR, f)) + .filter((f) => fs.statSync(f).isFile()); + if (!files.length) throw new Error(`${DIST_DIR} 裡沒有 .exe / .zip,先執行 npm run dist`); + + console.log("產物 :"); + for (const f of files) console.log(` ${path.basename(f)} ${(fs.statSync(f).size / 1048576).toFixed(1)} MB`); + + // 附件限制:超過就先講清楚,不要傳到一半才失敗 + const settings = await request("GET", `${base}/api/v1/settings/attachment`, { headers: { Accept: "application/json" } }); + if (settings.json) { + const maxMB = Number(settings.json.max_size); + const types = String(settings.json.allowed_types || ""); + const anyType = types.includes("*/*") || types.trim() === ""; + const problems = []; + for (const f of files) { + const sizeMB = fs.statSync(f).size / 1048576; + if (maxMB && sizeMB > maxMB) problems.push(`${path.basename(f)} 為 ${sizeMB.toFixed(1)} MB,超過上限 ${maxMB} MB`); + const ext = path.extname(f).toLowerCase(); + if (!anyType && !types.split(",").map((t) => t.trim().toLowerCase()).includes(ext)) { + problems.push(`${path.basename(f)} 的副檔名 ${ext} 不在 allowed_types 內`); + } + } + if (problems.length) { + console.error("\n伺服器的附件設定擋住這次發佈:"); + for (const p of problems) console.error(` - ${p}`); + console.error("\n請在 Gitea 的 app.ini 調整後重啟:"); + console.error(" [attachment]\n MAX_SIZE = 300\n ALLOWED_TYPES = */*"); + process.exitCode = 1; + return; + } + } + + const existing = await api("GET", apiUrl(base, owner, repo, `releases/tags/${encodeURIComponent(TAG)}`)); + if (DRY_RUN) { + console.log(`\n[dry-run] release ${TAG} ${existing.status === 200 ? "已存在,會重用並覆蓋同名附件" : "不存在,會新建"}`); + console.log("[dry-run] 變更說明:"); + console.log(releaseNotes().split("\n").map((l) => ` ${l}`).join("\n")); + console.log("\n[dry-run] 沒有做任何寫入。"); + return; + } + + if (!TOKEN) throw new Error("缺少 GITEA_TOKEN 環境變數(需要 write:repository 權限)"); + + let release = existing.json; + if (existing.status !== 200) { + const created = await api("POST", apiUrl(base, owner, repo, "releases"), { + tag_name: TAG, + target_commitish: git("rev-parse", "--abbrev-ref", "HEAD"), + name: `Claude Pet ${TAG}`, + body: releaseNotes(), + draft: false, + prerelease: false, + }); + if (created.status !== 201) throw new Error(`建立 release 失敗(HTTP ${created.status}):${created.text}`); + release = created.json; + console.log(`\n已建立 release ${TAG}`); + } else { + console.log(`\nrelease ${TAG} 已存在,重用`); + } + + for (const f of files) { + const name = path.basename(f); + const dup = (release.assets || []).find((a) => a.name === name); + if (dup) { + const del = await api("DELETE", apiUrl(base, owner, repo, `releases/${release.id}/assets/${dup.id}`)); + console.log(` 移除舊的 ${name}(HTTP ${del.status})`); + } + console.log(` 上傳 ${name}`); + const res = await uploadAsset(base, owner, repo, release.id, f); + if (res.status !== 201) throw new Error(`上傳 ${name} 失敗(HTTP ${res.status}):${res.text}`); + console.log(` 完成 ${name}`); + } + + console.log(`\n發佈完成:${base}/${owner}/${repo}/releases/tag/${TAG}`); +} + +main().catch((err) => { + console.error(err.message || err); + process.exitCode = 1; +});