- transcript.py/wiki_api.py 等價移植為 transcript.mjs/wiki_api.mjs:extract/duration/redact/probe/pages/show/append 等子命令行為與 Python 版對拍一致;transcript.mjs 額外處理三個 Python→JS 移植地雷(json.dumps 間距、Unicode 碼點切片、Python repr 格式)。
- 新增 tokens 子命令與 turnTokens()/formatTokens():統計本輪 token 用量,Claude Code/Codex/Copilot 三種 transcript 格式皆支援(Copilot 逐輪只有 outputTokens,輸入固定「未判定」)。
- worklog.sh 條目改為七個固定 bullet(新增「token 用量」),hook 輸入解析同時接受 snake_case 與 Copilot 的 camelCase 欄位。
- hooks.json 的腳本定位邏輯補上 ~/.copilot/installed-plugins 搜尋路徑:先前只搜尋 .claude/.codex 的 plugins cache,導致 Copilot 上 hook 事件雖有觸發(agentStop 會被 Copilot 對應到 hooks.json 的 Stop key)卻找不到 worklog.sh。
- SKILL.md/README.md 同步更新腳本檔名、各助理支援範圍(Copilot 改為 ✅ 並附實測說明,Antigravity/OpenCode 補上不支援的具體原因)。
550 lines
22 KiB
JavaScript
550 lines
22 KiB
JavaScript
#!/usr/bin/env node
|
||
// ==============================================================================
|
||
// 用途:Gitea Wiki 讀寫工具(worklog 專用)。提供 token 解析、頁面讀取、
|
||
// 建立、append 追加(read-modify-write + 寫後驗證重試),供 worklog.sh
|
||
// 與 /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/<repo> 實際驗證權限。
|
||
* 優先序: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 <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://<host>/api/v1/repos/<owner>/<repo>` 之下的 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>。
|
||
* 週以星期六起算(六~五),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 <marker> [頁面] 自 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));
|
||
}
|