#!/usr/bin/env node // ============================================================================== // 用途:Gitea Wiki 讀寫工具(worklog 專用)。提供 token 解析、頁面讀取、 // 建立、append 追加(read-modify-write + 寫後驗證重試),供 worklog.mjs // 與 /jsc-doc:worklog skill 共用,避免兩份實作漂移。 // resolveToken() 的優先序實作對應 /jsc-shared:spec-gitea『token 解析 // 優先序』章節。mask() 已與 transcript.mjs 的 REDACT_PATTERNS(對應 // spec-gitea『機密遮蔽實作』章節)整併:精準抹除已知 secret 值後, // 再套用同一份通用格式規則,避免兩份遮蔽規則各自漂移。 // 本檔為 wiki_api.py 的等價 Node 移植:page-name/week 系列日期運算 // 已對拍 Python 版逐字元相同;probe/pages/show/append 已對本機 // 架設的假 Gitea API 伺服器驗證行為一致(詳見 G1-3 驗收記錄),未對 // 正式環境的真實 wiki 資料做寫入測試。 // 更新時間:2026/08/11 16:51:56 // 相依:Node.js 標準內建功能(node:https、node:zlib 皆不需要),無外部套件。 // 機密:token 一律從環境變數或本機憑證檔讀取,絕不輸出、絕不寫入任何檔案。 // ============================================================================== import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import https from "node:https"; import { URL } from "node:url"; import { redact as redactPatterns } from "./transcript.mjs"; const TAIPEI_OFFSET_MS = 8 * 60 * 60 * 1000; /** 取得 Asia/Taipei 時區的 yyyy/MM/dd HH:mm:ss 時間字串(等同 persona-lib.mjs 的 nowDisplay() 手法)。 */ export function nowStr(date = new Date()) { const shifted = new Date(Math.floor(date.getTime() / 1000) * 1000 + TAIPEI_OFFSET_MS); const pad = (n) => String(n).padStart(2, "0"); return `${shifted.getUTCFullYear()}/${pad(shifted.getUTCMonth() + 1)}/${pad(shifted.getUTCDate())} ${pad(shifted.getUTCHours())}:${pad(shifted.getUTCMinutes())}:${pad(shifted.getUTCSeconds())}`; } /** 輸出統一格式訊息([時間][階段][等級]: 訊息,一行一則),一律走 stderr 不污染 stdout。 */ export function log(level, message, stage = "wiki_api") { process.stderr.write(`[${nowStr()}][${stage}][${level}]: ${message}\n`); } /** * 遮蔽字串中的機密。先精準抹除已知的 secret 值(涵蓋不符合任何通用格式的 * token,例如 tea 設定檔內的自訂字串),再套用 transcript.mjs 的 REDACT_PATTERNS * (對應 /jsc-shared:spec-gitea『機密遮蔽實作』章節,同一份規則的唯一權威來源) * 做第二層通用格式掃描,避免 wiki_api.mjs 自己維護一份會漂移的規則。 */ export function mask(text, secret) { let out = text; if (secret) out = out.split(secret).join("***"); return redactPatterns(out); } // ------------------------------------------------------------------------------ // token 解析:GITEA_TOKEN → tea config → git-credentials // ------------------------------------------------------------------------------ function expandHome(p) { return p.startsWith("~/") ? path.join(os.homedir(), p.slice(2)) : p; } /** 從 tea 設定檔取出指定 host 的 token(找不到回 null)。 */ function tokenFromTea(host) { for (const candidate of ["~/.config/tea/config.yml", "~/.tea/config.yml"]) { const f = expandHome(candidate); if (!fs.existsSync(f)) continue; let raw; try { raw = fs.readFileSync(f, "utf8"); } catch { continue; } for (const block of raw.split(/^\s*-\s+name:/m)) { if (!block.includes(host)) continue; const m = block.match(/^\s*token:\s*["']?([A-Za-z0-9_-]+)/m); if (m) return m[1]; } } return null; } /** 從 ~/.git-credentials(credential.helper=store)取出指定 host 的密碼作為 token。 */ function tokenFromGitCredentials(host) { const f = expandHome("~/.git-credentials"); if (!fs.existsSync(f)) return null; let lines; try { lines = fs.readFileSync(f, "utf8").split("\n"); } catch { return null; } for (const line of lines) { const m = line.trim().match(/^https?:\/\/([^:]+):([^@]+)@(.+)$/); if (m && m[3] === host) return decodeURIComponent(m[2]); } return null; } /** * 依固定優先序解析可用 token,並以 GET /repos/ 實際驗證權限。 * 優先序:GITEA_TOKEN → tea 設定檔該 host 的 token → ~/.git-credentials。 * 回傳 [token, 來源說明];全部失敗回 [null, 說明]。 */ export async function resolveToken(host, repo) { const candidates = []; const env = process.env.GITEA_TOKEN; if (env) candidates.push([env, "GITEA_TOKEN"]); const tea = tokenFromTea(host); if (tea && tea !== env) candidates.push([tea, "tea 設定檔"]); const cred = tokenFromGitCredentials(host); if (cred && cred !== env && cred !== tea) candidates.push([cred, "git-credentials"]); if (!candidates.length) return [null, "找不到任何可用憑證來源"]; for (const [token, source] of candidates) { const [code] = await request("GET", `https://${host}/api/v1/repos/${repo}`, token, null); if (code === 200) return [token, source]; log("DBG", `${source} 對 ${host} 驗證失敗(HTTP ${code}),改試下一個來源`); } return [null, `${candidates.length} 個憑證來源全部驗證失敗`]; } // ------------------------------------------------------------------------------ // HTTP // ------------------------------------------------------------------------------ /** * 發出 Gitea API 請求,回傳 [HTTP 狀態碼, 回應內文字串]。網路層錯誤以 0 表示。 * 本函式實作 /jsc-shared:spec-gitea 的『API 呼叫慣例』章節: * Authorization 標頭帶 `token `、payload 以 UTF-8 JSON 編碼。 */ function request(method, url, token, payload) { return new Promise((resolve) => { let data = null; if (payload !== null && payload !== undefined) { data = Buffer.from(JSON.stringify(payload), "utf8"); } let target; try { target = new URL(url); } catch (err) { resolve([0, String(err.message || err)]); return; } const headers = { Authorization: `token ${token}`, Accept: "application/json", }; if (data) { headers["Content-Type"] = "application/json"; headers["Content-Length"] = String(data.length); } const req = https.request( { method, hostname: target.hostname, port: target.port || 443, path: target.pathname + target.search, headers, timeout: 30000, }, (res) => { const chunks = []; res.on("data", (chunk) => chunks.push(chunk)); res.on("end", () => { resolve([res.statusCode, Buffer.concat(chunks).toString("utf8")]); }); }, ); req.on("timeout", () => { req.destroy(new Error("timeout")); }); req.on("error", (err) => { resolve([0, String(err.message || err)]); }); if (data) req.write(data); req.end(); }); } /** * 組出 repo 層級的 wiki API base URL。 * 本函式實作 /jsc-shared:spec-gitea 的『API 呼叫慣例』章節:base 為 * `https:///api/v1/repos//` 之下的 wiki 路徑。 */ function apiBase(host, repo) { return `https://${host}/api/v1/repos/${repo}/wiki`; } // ------------------------------------------------------------------------------ // wiki 操作 // ------------------------------------------------------------------------------ /** * 列出 wiki 全部頁面(分頁完整讀取),回傳 [狀態, 頁面清單]。 * 狀態為 'ok'/'missing'(wiki 尚未初始化)/'error'。清單元素含 title 與 sub_url。 */ export async function listPages(host, repo, token) { const pages = []; let pageNo = 1; const limit = 50; for (;;) { const url = `${apiBase(host, repo)}/pages?page=${pageNo}&limit=${limit}`; const [code, body] = await request("GET", url, token, null); if (code === 404) return ["missing", []]; if (code !== 200) return ["error", []]; let batch; try { batch = JSON.parse(body); } catch { return ["error", []]; } if (!Array.isArray(batch)) return ["error", []]; pages.push(...batch); if (batch.length < limit) return ["ok", pages]; pageNo += 1; } } /** * 以 title 查出 Gitea 實際的 sub_url。 * Gitea wiki 會對 title 做轉義(`-` 代表空格,實際 dash 另有轉義形式,例如 * title `Worklog-2026-07-W4` 的 sub_url 為 `Worklog-2026-07-W4.-`),因此讀寫 * 一律以查表得到的 sub_url 為準,不自行猜測轉義規則。 * 本函式實作 /jsc-shared:spec-gitea 的『Wiki 頁名轉義規則』第 2 點: * 以 `GET /wiki/pages` 查表找 sub_url,不用字串取代規則反推。找不到回 null。 */ export async function resolveSubUrl(host, repo, token, title) { const [status, pages] = await listPages(host, repo, token); if (status !== "ok") return null; for (const item of pages) { if (item.title === title) return item.sub_url || title; } return null; } /** * 讀取 wiki 頁面內容(page 可傳 title 或 sub_url,內部會自動解析)。 * 回傳 [狀態, 內容字串];狀態為 'ok'(存在)、'missing'(404)、'error'(其他失敗, * 內容為遮蔽後的錯誤訊息)。 */ export async function getPage(host, repo, token, page) { const subUrl = (await resolveSubUrl(host, repo, token, page)) || page; const url = `${apiBase(host, repo)}/page/${encodeURIComponent(subUrl)}`; const [code, body] = await request("GET", url, token, null); if (code === 404) return ["missing", ""]; if (code !== 200) return ["error", mask(`HTTP ${code} ${body.slice(0, 200)}`, token)]; let data; try { data = JSON.parse(body); } catch { return ["error", "回應不是合法 JSON"]; } const raw = data.content_base64 || ""; try { return ["ok", Buffer.from(raw, "base64").toString("utf8")]; } catch { return ["error", "content_base64 解碼失敗"]; } } /** 建立新的 wiki 頁面(wiki 尚未初始化時亦由此初始化)。回傳 [是否成功, 訊息]。 */ export async function createPage(host, repo, token, page, content, message) { const url = `${apiBase(host, repo)}/new`; const payload = { title: page, content_base64: Buffer.from(content, "utf8").toString("base64"), message, }; const [code, body] = await request("POST", url, token, payload); if (code === 201 || code === 200) return [true, `已建立頁面 ${page}`]; return [false, mask(`建立頁面失敗 HTTP ${code} ${body.slice(0, 200)}`, token)]; } /** 刪除 wiki 頁面(page 可傳 title 或 sub_url)。回傳 [是否成功, 訊息]。 */ export async function deletePage(host, repo, token, page) { const subUrl = (await resolveSubUrl(host, repo, token, page)) || page; const url = `${apiBase(host, repo)}/page/${encodeURIComponent(subUrl)}`; const [code, body] = await request("DELETE", url, token, null); if (code === 204 || code === 200) return [true, `已刪除頁面 ${page}`]; return [false, mask(`刪除頁面失敗 HTTP ${code} ${body.slice(0, 200)}`, token)]; } /** 整頁覆寫既有 wiki 頁面(append 由呼叫端先合併內容)。回傳 [是否成功, 訊息]。 */ export async function updatePage(host, repo, token, page, content, message) { const subUrl = (await resolveSubUrl(host, repo, token, page)) || page; const url = `${apiBase(host, repo)}/page/${encodeURIComponent(subUrl)}`; const payload = { title: page, content_base64: Buffer.from(content, "utf8").toString("base64"), message, }; const [code, body] = await request("PATCH", url, token, payload); if (code === 200 || code === 201) return [true, `已更新頁面 ${page}`]; return [false, mask(`更新頁面失敗 HTTP ${code} ${body.slice(0, 200)}`, token)]; } /** * 將條目追加到週頁尾端:讀取現有內容 → 合併 → 寫回 → 寫後讀取驗證。 * marker 為條目內唯一字串(時間戳+session 短碼),用於驗證自己的內容確實落地; * 多個 session 同時寫入時,驗證失敗會重讀最新內容重試,避免互相覆蓋。 * 回傳 [是否成功, 訊息]。 */ export async function appendEntry(host, repo, token, page, header, entry, marker, retries = 3) { for (let attempt = 1; attempt <= retries; attempt++) { const [status, current] = await getPage(host, repo, token, page); if (status === "error") return [false, `讀取頁面失敗:${current}`]; if (status === "missing") { const content = `${header}\n\n${entry}\n`; const [ok, msg] = await createPage(host, repo, token, page, content, `worklog: 建立 ${page}`); if (!ok) { log("WRN", `第 ${attempt} 次建立失敗:${msg}`); continue; } } else { if (current.includes(marker)) return [true, "條目已存在,無需重複寫入"]; let body = current.replace(/\n+$/, ""); if (!body) body = header; const content = `${body}\n\n${entry}\n`; const [ok, msg] = await updatePage(host, repo, token, page, content, `worklog: 追加 ${marker}`); if (!ok) { log("WRN", `第 ${attempt} 次寫入失敗:${msg}`); continue; } } const [verifyStatus, verifyContent] = await getPage(host, repo, token, page); if (verifyStatus === "ok" && verifyContent.includes(marker)) { return [true, `條目已寫入 ${page}(第 ${attempt} 次嘗試)`]; } log("WRN", `第 ${attempt} 次寫後驗證未找到條目,準備重試`); } return [false, `重試 ${retries} 次仍未成功寫入 ${page}`]; } // ------------------------------------------------------------------------------ // 週頁命名 // ------------------------------------------------------------------------------ // 週的定義:星期六起算(六~五),週頁以該週起始的星期六為錨點命名。 // 舊規則以 ceil(日/7) 分週,換頁點固定落在每月 8/15/22/29 號,會把同一個工作週 // 切成兩頁(例:2026/07/28 二 在 W4、07/29 三 卻跳到 W5),使用者開著舊頁會誤判成 // 「worklog 停止記錄」。改以星期六為界後,換頁一律發生在週六,與星期對齊。 const WEEK_START_WEEKDAY = 6; // JS Date#getUTCDay():週日 0、週一 1 …… 週五 5、週六 6 /** 依台北時區「牆上時間」建立一個可用 UTC getter 讀取的 Date(等同 persona-lib.mjs 的位移手法)。 */ function taipeiWallClock(date = new Date()) { return new Date(Math.floor(date.getTime() / 1000) * 1000 + TAIPEI_OFFSET_MS); } /** 把「台北牆上時間」的 Date 換回真實 epoch(taipeiWallClock 的逆運算)。 */ function fromTaipeiWallClock(wall) { return new Date(wall.getTime() - TAIPEI_OFFSET_MS); } /** 取得指定時間所屬工作週的起始日(該週的星期六;當天就是星期六時回傳當天),回傳台北牆上時間 Date。 */ export function weekStart(when) { const wall = when ? taipeiWallClock(when) : taipeiWallClock(); const diff = (wall.getUTCDay() - WEEK_START_WEEKDAY + 7) % 7; const start = new Date(wall); start.setUTCDate(start.getUTCDate() - diff); start.setUTCHours(0, 0, 0, 0); return start; } /** * 從週頁名稱反推該週起始的星期六(回傳台北牆上時間 Date)。 * 供手動指定頁面時產生正確標題;格式不符或該月不存在第 n 個星期六時回傳 null。 */ export function weekStartFromPage(page) { if (!page) return null; const m = String(page).trim().match(/^Worklog-(\d{4})-(\d{2})-W(\d)$/); if (!m) return null; const year = Number(m[1]); const month = Number(m[2]); const week = Number(m[3]); const firstDay = new Date(Date.UTC(year, month - 1, 1)); const diff = (WEEK_START_WEEKDAY - firstDay.getUTCDay() + 7) % 7; const firstSaturday = new Date(firstDay); firstSaturday.setUTCDate(firstSaturday.getUTCDate() + diff); const start = new Date(firstSaturday); start.setUTCDate(start.getUTCDate() + 7 * (week - 1)); return start.getUTCMonth() === month - 1 ? start : null; } /** * 依台灣時區產生週頁名稱 Worklog-yyyy-MM-W。 * 週以星期六起算(六~五),n =該週起始的星期六是當月第幾個星期六。 * 跨月的一週歸屬起始星期六所在的月份,確保同一週只會有一頁 * (例:2026/08/29 六 ~ 09/04 五 都寫入 Worklog-2026-08-W5)。 */ export function weekPageName(when) { const start = weekStart(when); const week = Math.floor((start.getUTCDate() - 1) / 7) + 1; const pad = (n) => String(n).padStart(2, "0"); return `Worklog-${start.getUTCFullYear()}-${pad(start.getUTCMonth() + 1)}-W${week}`; } /** * 產生週頁首行標題(例:# 2026 年 07 月 第 4 週工作紀錄(07/25 六 ~ 07/31 五))。 * 標題含日期範圍,讓開頁的人一眼看出這頁涵蓋哪幾天,不必回頭推算週次。 * 傳入 page 時以頁名反推所屬週,避免手動補寫舊頁時寫入當下這週的標題。 */ export function weekPageHeader(page, when) { const start = weekStartFromPage(page) || weekStart(when); const end = new Date(start); end.setUTCDate(end.getUTCDate() + 6); const week = Math.floor((start.getUTCDate() - 1) / 7) + 1; const pad = (n) => String(n).padStart(2, "0"); return ( `# ${start.getUTCFullYear()} 年 ${pad(start.getUTCMonth() + 1)} 月 第 ${week} 週工作紀錄` + `(${pad(start.getUTCMonth() + 1)}/${pad(start.getUTCDate())} 六 ~ ${pad(end.getUTCMonth() + 1)}/${pad(end.getUTCDate())} 五)` ); } // ------------------------------------------------------------------------------ // CLI // ------------------------------------------------------------------------------ const USAGE = `用法:wiki_api.mjs <子命令> [參數] probe 檢查 host/repo/token/wiki API 可用性 page-name 印出當週頁面名稱 pages 列出全部頁面(title 與實際 sub_url) show [頁面] 印出指定頁面內容(預設當週頁) append [頁面] 自 stdin 讀取條目內容並追加(預設當週頁) init [頁面] 若當週頁不存在則建立(僅含標題) delete <頁面> 刪除指定頁面 環境變數:WORKLOG_HOST(必要)、WORKLOG_REPO(必要)、GITEA_TOKEN(選用,會自動 fallback) `; function readStdin() { try { return fs.readFileSync(0, "utf8"); } catch { return ""; } } /** 讀取並檢查必要環境變數,回傳 [host, repo];缺少時結束程式。 */ function readEnv() { const host = (process.env.WORKLOG_HOST || "").trim(); const repo = (process.env.WORKLOG_REPO || "").trim(); if (!host || !repo) { log("ERR", "缺少 WORKLOG_HOST 或 WORKLOG_REPO"); process.exit(2); } return [host, repo]; } /** CLI 進入點:解析子命令並執行對應 wiki 操作。 */ async function main(argv) { if (!argv.length || argv[0] === "-h" || argv[0] === "--help") { process.stdout.write(USAGE); return 0; } const cmd = argv[0]; if (cmd === "page-name") { process.stdout.write(`${weekPageName()}\n`); return 0; } const [host, repo] = readEnv(); const [token, source] = await resolveToken(host, repo); if (!token) { log("ERR", `無可用 token:${source}`); return 2; } if (cmd === "probe") { log("INF", `token 來源:${source}`); const [code, body] = await request("GET", `https://${host}/api/v1/version`, token, null); log("INF", `Gitea 版本查詢 HTTP ${code} ${body.slice(0, 80)}`); const [status] = await getPage(host, repo, token, weekPageName()); log("INF", `當週頁 ${weekPageName()} 狀態:${status}`); return 0; } if (cmd === "pages") { const [status, pages] = await listPages(host, repo, token); if (status !== "ok") { log(status === "missing" ? "WRN" : "ERR", `頁面清單狀態:${status}`); return status === "missing" ? 0 : 1; } for (const item of pages) process.stdout.write(`${item.title}\t${item.sub_url}\n`); return 0; } if (cmd === "delete") { if (argv.length < 2) { log("ERR", "delete 需要頁面名稱"); return 2; } const [ok, msg] = await deletePage(host, repo, token, argv[1]); log(ok ? "INF" : "ERR", msg); return ok ? 0 : 1; } if (cmd === "show") { const page = argv.length > 1 ? argv[1] : weekPageName(); const [status, content] = await getPage(host, repo, token, page); if (status === "ok") { process.stdout.write(`${content}\n`); return 0; } log(status === "missing" ? "WRN" : "ERR", `頁面 ${page} 狀態:${status} ${content}`); return status === "missing" ? 0 : 1; } if (cmd === "init") { const page = argv.length > 1 ? argv[1] : weekPageName(); const [status] = await getPage(host, repo, token, page); if (status === "ok") { log("INF", `頁面 ${page} 已存在,不重建`); return 0; } const [ok, msg] = await createPage(host, repo, token, page, `${weekPageHeader(page)}\n`, `worklog: 初始化 ${page}`); log(ok ? "INF" : "ERR", msg); return ok ? 0 : 1; } if (cmd === "append") { if (argv.length < 2) { log("ERR", "append 需要 marker 參數"); return 2; } const marker = argv[1]; const page = argv.length > 2 ? argv[2] : weekPageName(); const entry = readStdin().trim(); if (!entry) { log("WRN", "條目內容為空,不寫入"); return 0; } const [ok, msg] = await appendEntry(host, repo, token, page, weekPageHeader(page), entry, marker); log(ok ? "INF" : "ERR", msg); return ok ? 0 : 1; } log("ERR", `未知子命令:${cmd}`); process.stdout.write(USAGE); return 2; } if (import.meta.url === `file://${process.argv[1]}`) { main(process.argv.slice(2)).then((code) => process.exit(code)); }