// persona-gitea.mjs — 人格的 Gitea 儲存層(人格編號 = 存取庫名稱) // // 設計: // * 本機 `~/.claude/personas//` 仍是**工作副本**,hook 每輪照常讀寫本機檔案(零延遲)。 // * Gitea 上每個人格一個私有存取庫,名稱就是人格編號(例如 `ASUNA-01`)。 // * 依**更新頻率**分兩區: // - 檔案區(主存取庫):每輪都在變的活狀態(情緒、短期記憶、心裡話、逐字稿…) // - Wiki 區:低頻的身分與長期結構(IDENTITY/SOUL、長期記憶、心智圖、關係圖),當設定百科看 // * 兩區各自 clone 在 `/.sync//`,push 前把工作副本的檔案複製進去再 commit。 // (不在人格目錄本身放 .git:一個目錄要同時屬於兩個 repo 是行不通的。) // // 網路一律**失敗不阻斷**:Gitea 掛掉、沒設 token、離線,人格照樣能聊天。 import fs from "node:fs"; import path from "node:path"; import crypto from "node:crypto"; import { spawn, spawnSync } from "node:child_process"; import * as pl from "./persona-lib.mjs"; export const SYNC_DIRNAME = ".sync"; export const DEFAULT_MIN_PUSH_SECONDS = 60; // --------------------------------------------------------------------------- // // 人格編號:英文名全大寫 + 兩位索引(同名才遞增) // --------------------------------------------------------------------------- // export const CODE_RE = /^[A-Z][A-Z0-9]{0,23}-\d{2}$/; export const validCode = (code) => typeof code === "string" && CODE_RE.test(code); /** `Asuna` / `asuna sao` / `Shen Yu` → `ASUNA` / `ASUNASAO` / `SHENYU`。非拉丁字元一律拒絕。 */ export function normalizeRomaji(romaji) { const raw = String(romaji ?? "").normalize("NFKD").replace(/[̀-ͯ]/g, ""); const letters = raw.replace(/[^A-Za-z0-9]/g, "").toUpperCase(); if (!letters || !/^[A-Z]/.test(letters)) return null; return letters.slice(0, 24); } export const codePrefix = (code) => String(code ?? "").split("-")[0] || ""; /** 掃全倉庫,回傳這個英文名下一個可用的編號(同名遞增,兩位數)。 */ export function nextCode(romaji) { const prefix = normalizeRomaji(romaji); if (!prefix) return null; let max = 0; for (const slug of pl.listPersonas()) { for (const candidate of [pl.loadConfig(slug).code, slug]) { if (!validCode(candidate) || codePrefix(candidate) !== prefix) continue; max = Math.max(max, Number(candidate.split("-")[1]) || 0); } } if (max >= 99) return null; return `${prefix}-${String(max + 1).padStart(2, "0")}`; } /** 這個人格的編號:config.code 優先,其次目錄名本身就是編號。 */ export function personaCode(slug) { const code = pl.loadConfig(slug).code; if (validCode(code)) return code; return validCode(slug) ? slug : null; } // --------------------------------------------------------------------------- // // 兩個儲存區:依更新頻率切 // --------------------------------------------------------------------------- // export const AREAS = { // 高頻:每輪對話都在變 → 主存取庫的檔案區,Gitea 網頁上一眼看到最新狀態 files: { key: "files", label: "檔案區", why: "高頻:每輪對話都在變", paths: [ "state/config.json", "state/emotion.json", "state/inner.jsonl", "state/said.jsonl", "state/felt.jsonl", "state/sleep.json", "memory/short-term.jsonl", "memory/inbox/", "mindmap/threads/", "journal/", ], }, // 低頻:身分與長期結構 → Wiki,當「設定百科」讀 wiki: { key: "wiki", label: "Wiki 區", why: "低頻:身分與長期結構,當設定百科看", paths: [ "IDENTITY.md", "SOUL.md", "AGENTS.md", "USER.md", "icon.svg", "icon.png", "icon/", "memory/INDEX.md", "memory/long-term/", "mindmap/semantic.mmd", "relations/graph.json", "relations/graph.mmd", ], }, }; export const AREA_KEYS = Object.keys(AREAS); export const syncDir = (slug, area) => path.join(pl.personaDir(slug), SYNC_DIRNAME, area); export const syncStatePath = (slug) => path.join(pl.personaDir(slug), "state", "sync.json"); export function loadSyncState(slug) { const data = pl.readJson(syncStatePath(slug), {}) ?? {}; data.areas ??= {}; for (const key of AREA_KEYS) data.areas[key] ??= {}; return data; } export const saveSyncState = (slug, data) => pl.writeJson(syncStatePath(slug), data); // --------------------------------------------------------------------------- // // 環境與 API // --------------------------------------------------------------------------- // export function giteaEnv() { const host = String(process.env.PERSONA_GITEA_HOST || process.env.GITEA_HOST || "").replace(/\/+$/, ""); const token = String(process.env.PERSONA_GITEA_TOKEN || process.env.GITEA_TOKEN || ""); const owner = String(process.env.PERSONA_GITEA_OWNER || ""); const off = /^(0|off|false|no)$/i.test(String(process.env.PERSONA_GITEA || "")); return { host, token, owner, enabled: Boolean(host && token) && !off, disabled: off }; } export function giteaProblem() { const env = giteaEnv(); if (env.disabled) return "PERSONA_GITEA 被設為關閉。"; if (!env.host) return "沒有 `PERSONA_GITEA_HOST`/`GITEA_HOST`。"; if (!env.token) return "沒有 `PERSONA_GITEA_TOKEN`/`GITEA_TOKEN`。"; return null; } async function api(method, route, body = null) { const { host, token } = giteaEnv(); const res = await fetch(`${host}/api/v1${route}`, { method, headers: { Authorization: `token ${token}`, "Content-Type": "application/json", Accept: "application/json", }, body: body === null ? undefined : JSON.stringify(body), }); const text = await res.text(); let json = null; try { json = text ? JSON.parse(text) : null; } catch { json = null; } return { ok: res.ok, status: res.status, json, text }; } const ownerCachePath = () => path.join(pl.runtimeDir(), "gitea.json"); /** 存取庫的擁有者:`PERSONA_GITEA_OWNER` 優先,否則用 token 本人的帳號(會快取)。 */ export async function resolveOwner() { const env = giteaEnv(); if (env.owner) return env.owner; const cached = pl.readJson(ownerCachePath(), {}) ?? {}; if (cached.host === env.host && cached.login) return cached.login; const res = await api("GET", "/user"); if (!res.ok || !res.json?.login) throw new Error(`取不到 Gitea 帳號(HTTP ${res.status}):${res.text.slice(0, 120)}`); pl.writeJson(ownerCachePath(), { host: env.host, login: res.json.login, cached_at: pl.nowIso() }); return res.json.login; } export async function getRepo(owner, code) { const res = await api("GET", `/repos/${owner}/${encodeURIComponent(code)}`); return res.ok ? res.json : null; } /** 建立(或沿用)人格的私有存取庫。存取庫名稱 = 人格編號。 */ export async function ensureRepo(owner, code, { description = "", private_ = true } = {}) { const existing = await getRepo(owner, code); if (existing) return { repo: existing, created: false }; const env = giteaEnv(); const me = await resolveOwner(); const route = owner === me ? "/user/repos" : `/orgs/${owner}/repos`; const res = await api("POST", route, { name: code, private: private_, description: description || `jsc-persona 人格 ${code}`, auto_init: false, }); if (!res.ok) throw new Error(`建立存取庫 ${owner}/${code} 失敗(HTTP ${res.status}):${res.text.slice(0, 160)}`); void env; return { repo: res.json, created: true }; } /** Wiki 的 git repo 要有第一頁才會存在,用 API 建 Home 頁。 */ export async function ensureWikiHome(owner, code, content) { const page = await api("GET", `/repos/${owner}/${encodeURIComponent(code)}/wiki/page/Home`); if (page.ok) return false; const res = await api("POST", `/repos/${owner}/${encodeURIComponent(code)}/wiki/new`, { title: "Home", content_base64: Buffer.from(content, "utf8").toString("base64"), message: "init: 人格設定百科", }); if (!res.ok) throw new Error(`建立 Wiki 首頁失敗(HTTP ${res.status}):${res.text.slice(0, 160)}`); return true; } /** 把人格圖示設成存取庫頭像(Gitea 各處的清單就會顯示這個人格的臉)。 */ export async function setRepoAvatar(owner, code, pngBuffer) { const res = await api("POST", `/repos/${owner}/${encodeURIComponent(code)}/avatar`, { image: Buffer.from(pngBuffer).toString("base64"), }); return res.ok; } export const repoUrl = (host, owner, code, area) => `${host}/${owner}/${encodeURIComponent(code)}${area === "wiki" ? ".wiki" : ""}.git`; // --------------------------------------------------------------------------- // // git(用 CLI,認證走 http.extraHeader,不把 token 寫進 .git/config 也不塞進參數) // --------------------------------------------------------------------------- // function gitEnv() { const { token } = giteaEnv(); const env = { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_AUTHOR_NAME: process.env.PERSONA_GIT_NAME || "jsc-persona", GIT_AUTHOR_EMAIL: process.env.PERSONA_GIT_EMAIL || "persona@localhost", GIT_COMMITTER_NAME: process.env.PERSONA_GIT_NAME || "jsc-persona", GIT_COMMITTER_EMAIL: process.env.PERSONA_GIT_EMAIL || "persona@localhost", }; if (token) { env.GIT_CONFIG_COUNT = "1"; env.GIT_CONFIG_KEY_0 = "http.extraHeader"; env.GIT_CONFIG_VALUE_0 = `Authorization: token ${token}`; } return env; } export function git(args, cwd = null) { const proc = spawnSync("git", args, { cwd: cwd || undefined, env: gitEnv(), encoding: "utf8" }); return { ok: proc.status === 0, status: proc.status, stdout: String(proc.stdout || "").trim(), stderr: String(proc.stderr || "").trim(), }; } function gitOrThrow(args, cwd, what) { const res = git(args, cwd); if (!res.ok) throw new Error(`${what} 失敗:git ${args.join(" ")}\n ${res.stderr || res.stdout}`); return res; } // 這些 clone 只有本 CLI 會動,而且每個 git 都是同步跑完才回來,所以一個超過 // STALE_LOCK_SECONDS 還在的 index.lock 一定是上一次跑到一半被砍掉留下的殘骸。 // 不自己清的話整區會一直失敗,而 sleeper 被隔離 hook 擋著、連自己的鎖都刪不掉。 const STALE_LOCK_SECONDS = 30; export function clearStaleIndexLock(dir) { const lock = path.join(dir, ".git", "index.lock"); let stat; try { stat = fs.statSync(lock); } catch { return null; } const ageSeconds = (Date.now() - stat.mtimeMs) / 1000; if (ageSeconds < STALE_LOCK_SECONDS) return null; try { fs.rmSync(lock); } catch (err) { return { cleared: false, ageSeconds, reason: String(err.message || err) }; } return { cleared: true, ageSeconds }; } /** 確保 `/.sync//` 是該區的 clone;空存取庫也能處理。 */ export function ensureClone(slug, area, url) { const dir = syncDir(slug, area); if (fs.existsSync(path.join(dir, ".git"))) { git(["remote", "set-url", "origin", url], dir); return dir; } fs.mkdirSync(path.dirname(dir), { recursive: true }); fs.rmSync(dir, { recursive: true, force: true }); const cloned = git(["clone", "--quiet", url, dir]); if (!cloned.ok) { // 空存取庫 clone 會警告但成功;真的失敗才自己 init fs.mkdirSync(dir, { recursive: true }); gitOrThrow(["init", "--quiet", "-b", "main"], dir, "初始化"); gitOrThrow(["remote", "add", "origin", url], dir, "設定 remote"); } return dir; } // --------------------------------------------------------------------------- // // 檔案搬運:工作副本 <-> clone // --------------------------------------------------------------------------- // function listAreaFiles(root, area) { const out = []; for (const rel of AREAS[area].paths) { const abs = path.join(root, rel); if (rel.endsWith("/")) { let entries = []; try { entries = fs.readdirSync(abs, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { if (entry.isFile()) out.push(path.posix.join(rel.replace(/\/$/, ""), entry.name)); } continue; } if (fs.existsSync(abs) && fs.statSync(abs).isFile()) out.push(rel); } return out.sort(); } function listTrackedFiles(dir) { const res = git(["ls-files"], dir); return res.ok ? res.stdout.split("\n").map((s) => s.trim()).filter(Boolean).sort() : []; } export const WIKI_MANIFEST = "_paths.json"; const WIKI_RESERVED = new Set(["Home.md", "Icon.md", WIKI_MANIFEST]); const WIKI_PREFIX = { memory: "Memory", mindmap: "Mindmap", relations: "Relations" }; /** * Gitea 的 wiki **只有根目錄的 .md 會變成頁面**(1.27 實測:子目錄頁面連結 404), * 所以低頻區的檔案要攤平成根層檔名(`memory/long-term/x.md` → `Memory-x.md`, * 網頁上顯示為「Memory x」),再用 `_paths.json` 記住原本的路徑,pull 時才還原得回去。 */ export function wikiName(rel) { if (!rel.includes("/")) return rel; // 只有 .md 需要攤平——Gitea 只把「根目錄的 .md」當成頁面。 // 其他附件(圖片、mmd、json)放子資料夾沒問題:實測 /wiki/raw/icon/portrait.svg 取得到, // 而且 Markdown 圖片語法會被自動改寫成那個 raw 路徑。 if (!/\.md$/i.test(rel)) return rel; const parts = rel.split("/"); const file = parts.pop(); return `${WIKI_PREFIX[parts[0]] || parts[0]}-${file}`; } /** 工作副本相對路徑 → clone 內的檔名。攤平後撞名的話補上短雜湊。 */ function buildNameMap(root, area) { const map = new Map(); const taken = new Map(); for (const rel of listAreaFiles(root, area)) { let name = area === "wiki" ? wikiName(rel) : rel; if (taken.has(name) && taken.get(name) !== rel) { const ext = path.extname(name); const hash = crypto.createHash("md5").update(rel).digest("hex").slice(0, 6); name = `${name.slice(0, name.length - ext.length)}~${hash}${ext}`; } taken.set(name, rel); map.set(rel, name); } return map; } const readManifest = (area, dir) => area === "wiki" ? pl.readJson(path.join(dir, WIKI_MANIFEST), {}) ?? {} : {}; /** clone 內的檔名 → 工作副本相對路徑。 */ export function cloneNameToRel(area, dir, name) { if (area !== "wiki") return name; return readManifest(area, dir)[name] || name; } /** 把工作副本裡屬於這一區的檔案複製進 clone;clone 裡多出來的(已刪除的)一併移除。 */ function stageArea(slug, area, dir) { const root = pl.personaDir(slug); const map = buildNameMap(root, area); const keep = new Set(map.values()); if (area === "wiki") for (const name of WIKI_RESERVED) keep.add(name); for (const [rel, name] of map) { const target = path.join(dir, name); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.copyFileSync(path.join(root, rel), target); } if (area === "wiki") { const manifest = {}; for (const [rel, name] of map) manifest[name] = rel; pl.writeText(path.join(dir, WIKI_MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`); } for (const name of listTrackedFiles(dir)) { if (keep.has(name)) continue; fs.rmSync(path.join(dir, name), { force: true }); } return [...map.keys()]; } /** 把 clone 裡的檔案寫回工作副本(wiki 區依 `_paths.json` 還原成原本的路徑)。 */ function unstageArea(slug, area, dir, only = null) { const root = pl.personaDir(slug); const manifest = readManifest(area, dir); const written = []; for (const name of listTrackedFiles(dir)) { if (area === "wiki" && WIKI_RESERVED.has(name)) continue; if (only && !only.has(name)) continue; const src = path.join(dir, name); if (!fs.existsSync(src)) continue; const rel = manifest[name] || name; const target = path.join(root, rel); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.copyFileSync(src, target); written.push(rel); } return written; } /** * Wiki 的「形象圖」頁:把 icon.svg 與 icon.png 都保存在 Wiki 並展示出來, * 附上這張圖是從哪張參考照片來的(可查證)。 */ export function wikiIconPage(slug, code) { const ident = pl.identityFields(slug); const config = pl.loadConfig(slug); const icon = config.icon || {}; const src = icon.source || {}; const root = pl.personaDir(slug); const hasSvg = fs.existsSync(path.join(root, "icon.svg")); const hasPng = fs.existsSync(path.join(root, "icon.png")); let renders = []; try { renders = fs.readdirSync(path.join(root, "icon")) .filter((n) => /\.(svg|png)$/i.test(n)) .sort() .map((name) => ({ name, ext: name.split(".").pop(), note: /(\d+)/.test(name) ? `${name.match(/(\d+)/)[1]}×${name.match(/(\d+)/)[1]} 高解析度` : "向量原稿", })); } catch { renders = []; } const styleLabel = { photo: "真實照片裁臉", portrait: "向量人物形象(有臉),配色取自參考照片", badge: "編號徽章(沒有參考照片時的樣式)", }[icon.style || "badge"]; return [ `# ${ident.Emoji ? `${ident.Emoji} ` : ""}${ident.Name || slug} 的形象圖`, "", `\`${code}\` ${styleLabel}`, "", // Gitea 只會改寫 **Markdown 圖片語法** 的路徑(→ /wiki/raw/...); // 用 HTML 會被瀏覽器當成相對於頁面網址,變成 303 破圖。 ...(hasSvg || hasPng ? [ ...(hasPng ? [`![${code} 形象圖](icon.png)`, ""] : []), "| 格式 | 檔案 | 用途 |", "| --- | --- | --- |", ...(hasSvg ? [`| SVG | [icon.svg](icon.svg) | 向量,可無限放大 |`] : []), ...(hasPng ? [`| PNG | [icon.png](icon.png) | 點陣,存取庫頭像 |`] : []), ...renders.map((r) => `| ${r.ext.toUpperCase()} | [icon/${r.name}](icon/${r.name}) | ${r.note} |`), "", ] : ["(尚未產生形象圖,執行 `/jsc-persona:persona-icon`。)", ""]), "## 這張圖怎麼來的", "", "| 欄位 | 內容 |", "| --- | --- |", `| 樣式 | ${icon.style || "badge"} |`, `| 尺寸 | ${icon.size || "?"}×${icon.size || "?"} |`, `| 調色盤 | ${icon.palette || "(由編號雜湊)"} |`, `| 參考來源 | ${src.url ? `[${src.url}](${src.url})` : "(無)"} |`, `| 造型說明 | ${src.note || "—"} |`, `| 參考日期 | ${src.date || "—"} |`, `| 產生時間 | ${icon.generated_at || "—"} |`, "", icon.style === "photo" ? "> 由 jsc-persona 從上述參考圖**裁出臉部**產生(自動臉部偵測),並套上圓角與瞳色外框。" : "> 由 jsc-persona 產生:配色取自角色**最新一次登場**的官方視覺。", "> 這裡保存的是產生出來的形象圖(SVG + PNG),供人格身分辨識使用。", "", ].join("\n"); } /** Wiki 首頁:讓 Gitea 上點進去就看得懂這是誰。 */ export function wikiHome(slug, code) { const ident = pl.identityFields(slug); const longTerm = pl.longTermEntries(slug); const relations = pl.loadRelations(slug); const hasIcon = fs.existsSync(path.join(pl.personaDir(slug), "icon.png")); const lines = [ `# ${ident.Emoji ? `${ident.Emoji} ` : ""}${ident.Name || slug} \`${code}\``, "", ...(hasIcon ? [`![${code}](icon.png)`, ""] : []), "> 由 jsc-persona 自動產生的人格設定百科。**低頻資料**(身分、長期記憶、心智圖、關係圖)放這裡;", "> 每輪都在變的活狀態(情緒、短期記憶、心裡話、逐字稿)在存取庫的檔案區。", "", "| 欄位 | 內容 |", "| --- | --- |", `| 編號 | \`${code}\` |`, ...["Name", "Creature", "Vibe", "Emoji", "Avatar"] .filter((k) => ident[k]) .map((k) => `| ${k} | ${ident[k]} |`), `| 長期記憶 | ${longTerm.length} 則 |`, `| 關係人 | ${relations.nodes.length} 位 |`, `| 圖示更新 | ${pl.loadConfig(slug).icon?.generated_at || "—"} |`, "", "## 頁面", "", "- [IDENTITY](IDENTITY) — 身分卡(Name / Creature / Vibe / Emoji / Avatar)", "- [SOUL](SOUL) — 靈魂:Core Truths / Boundaries / Vibe / Continuity", "- [AGENTS](AGENTS) — 操作規則 / [USER](USER) — 對使用者的理解", "- [Icon](Icon) — 人格形象圖(SVG + PNG)與它的來源", "- [Memory INDEX](Memory-INDEX) — 長期記憶索引", "", "### 長期記憶", "", ]; const memos = [...longTerm].sort((a, b) => Number(b.salience || 0) - Number(a.salience || 0)); for (const meta of memos.slice(0, 50)) { const page = wikiName(`memory/long-term/${path.basename(meta._path)}`).replace(/\.md$/, ""); const first = (meta._body || "").split("\n")[0] || ""; lines.push(`- [${meta._name}](${page})|${meta.type || "fact"}|顯著度 ${meta.salience ?? "?"}|${first.slice(0, 60)}`); } if (!memos.length) lines.push("(還沒有長期記憶)"); if (memos.length > 50) lines.push(`…以及另外 ${memos.length - 50} 則,見 [Memory INDEX](Memory-INDEX)。`); lines.push( "", "### 其他", "", "- `Mindmap-semantic.mmd` — 心智圖(Mermaid)", "- `Relations-graph.mmd` — 人際關係圖(Mermaid)/ `Relations-graph.json` — 原始資料", "- `icon/` — 高解析度形象圖(多尺寸) - `_paths.json` — 攤平前的路徑對照(勿手改)", "", "> Gitea 的 wiki 只有根目錄的 `.md` 會變成頁面,所以子目錄的檔案在這裡是攤平的檔名。", "", ); return lines.join("\n"); } // --------------------------------------------------------------------------- // // push / pull // --------------------------------------------------------------------------- // export function minPushSeconds() { const raw = Number(process.env.PERSONA_SYNC_MIN_SECONDS); return Number.isFinite(raw) && raw >= 0 ? raw : DEFAULT_MIN_PUSH_SECONDS; } export function pushDue(slug, area) { const last = loadSyncState(slug).areas[area]?.pushed_at; return !last || pl.ageSeconds(last) >= minPushSeconds(); } export async function pushArea(slug, area, { message = "", code = null, owner = null } = {}) { const problem = giteaProblem(); if (problem) return { ok: false, skipped: true, reason: problem }; const theCode = code || personaCode(slug); if (!theCode) return { ok: false, skipped: true, reason: `人格 \`${slug}\` 還沒有編號,先跑 \`code assign\`。` }; const theOwner = owner || (await resolveOwner()); const { host } = giteaEnv(); const dir = ensureClone(slug, area, repoUrl(host, theOwner, theCode, area)); if (area === "wiki") { pl.writeText(path.join(dir, "Home.md"), wikiHome(slug, theCode)); pl.writeText(path.join(dir, "Icon.md"), wikiIconPage(slug, theCode)); } const staged = stageArea(slug, area, dir); clearStaleIndexLock(dir); gitOrThrow(["add", "-A"], dir, "git add"); const dirty = git(["diff", "--cached", "--quiet"], dir); if (dirty.ok) { const state = loadSyncState(slug); state.areas[area] = { ...state.areas[area], checked_at: pl.nowIso() }; saveSyncState(slug, state); return { ok: true, changed: false, files: staged.length, area, code: theCode }; } gitOrThrow(["commit", "-q", "-m", message || `sync(${area}): ${pl.nowIso()}`], dir, "git commit"); let pushed = git(["push", "-q", "-u", "origin", "HEAD"], dir); if (!pushed.ok) { // 通常是別台機器先推了(non-fast-forward)。工作副本才是這台機器的真相來源, // 所以對齊遠端後把本機內容重新疊上去再推一次;真的有人同時在用,load 時的 pull 會擋下來。 const branch = git(["rev-parse", "--abbrev-ref", "HEAD"], dir).stdout || "main"; if (git(["fetch", "--quiet", "origin"], dir).ok && git(["rev-parse", "--verify", "--quiet", `origin/${branch}`], dir).ok) { git(["reset", "--hard", "--quiet", `origin/${branch}`], dir); stageArea(slug, area, dir); if (area === "wiki") { pl.writeText(path.join(dir, "Home.md"), wikiHome(slug, theCode)); pl.writeText(path.join(dir, "Icon.md"), wikiIconPage(slug, theCode)); } clearStaleIndexLock(dir); git(["add", "-A"], dir); if (!git(["diff", "--cached", "--quiet"], dir).ok) { git(["commit", "-q", "-m", `${message || "sync"}(與遠端合併後重推)`], dir); } } pushed = git(["push", "-q", "-u", "origin", "HEAD"], dir); } if (!pushed.ok) return { ok: false, area, code: theCode, reason: pushed.stderr || pushed.stdout }; const state = loadSyncState(slug); state.code = theCode; state.owner = theOwner; state.areas[area] = { pushed_at: pl.nowIso(), checked_at: pl.nowIso(), files: staged.length }; saveSyncState(slug, state); return { ok: true, changed: true, files: staged.length, area, code: theCode }; } /** * 拉回遠端內容。 * 衝突判定:clone 裡「還沒 commit 的本機改動」若正好也被遠端改到 → 停下來,不覆蓋本機。 */ export async function pullArea(slug, area, { code = null, owner = null, force = false } = {}) { const problem = giteaProblem(); if (problem) return { ok: false, skipped: true, reason: problem }; const theCode = code || personaCode(slug); if (!theCode) return { ok: false, skipped: true, reason: `人格 \`${slug}\` 還沒有編號。` }; const theOwner = owner || (await resolveOwner()); const { host } = giteaEnv(); const dir = ensureClone(slug, area, repoUrl(host, theOwner, theCode, area)); stageArea(slug, area, dir); // 先把本機現況放進 clone,才看得出本機動過什麼 clearStaleIndexLock(dir); const localChanged = new Set( git(["status", "--porcelain"], dir) .stdout.split("\n") .map((l) => l.slice(3).trim()) .filter(Boolean), ); const fetched = git(["fetch", "--quiet", "origin"], dir); if (!fetched.ok) return { ok: false, area, reason: fetched.stderr || "fetch 失敗" }; const head = git(["rev-parse", "--abbrev-ref", "HEAD"], dir).stdout || "main"; const remoteRef = `origin/${head}`; const exists = git(["rev-parse", "--verify", "--quiet", remoteRef], dir); if (!exists.ok) return { ok: true, area, code: theCode, empty: true, changed: [] }; const incoming = git(["diff", "--name-only", "HEAD", remoteRef], dir).stdout.split("\n").filter(Boolean); const conflicts = incoming.filter((f) => localChanged.has(f)); if (conflicts.length && !force) { git(["checkout", "--", "."], dir); return { ok: false, area, code: theCode, conflicts }; } git(["checkout", "--", "."], dir); const reset = git(["reset", "--hard", "--quiet", remoteRef], dir); if (!reset.ok) return { ok: false, area, reason: reset.stderr }; // 只寫回「遠端真的改過的」與「本機缺少的」。 // 不能無差別覆蓋:本機有較新但還沒 push 的內容時,那會把它蓋掉。 const root = pl.personaDir(slug); const restore = new Set(incoming); for (const name of listTrackedFiles(dir)) { if (area === "wiki" && WIKI_RESERVED.has(name)) continue; if (!fs.existsSync(path.join(root, cloneNameToRel(area, dir, name)))) restore.add(name); } const written = unstageArea(slug, area, dir, restore); const state = loadSyncState(slug); state.areas[area] = { ...state.areas[area], pulled_at: pl.nowIso() }; saveSyncState(slug, state); return { ok: true, area, code: theCode, changed: incoming, written }; } /** * 驗證某一區「本機 = 遠端」。 * push 回報成功不等於遠端真的有東西(網路中斷、權限、非快轉都可能), * 形象圖這種一定要出現在 Wiki 的檔案更需要一個明確的檢查點。 */ export async function verifyArea(slug, area, { code = null, owner = null } = {}) { const problem = giteaProblem(); if (problem) return { ok: false, skipped: true, area, reason: problem }; const theCode = code || personaCode(slug); if (!theCode) return { ok: false, skipped: true, area, reason: "沒有人格編號" }; const theOwner = owner || (await resolveOwner()); const { host } = giteaEnv(); const dir = ensureClone(slug, area, repoUrl(host, theOwner, theCode, area)); if (!git(["fetch", "--quiet", "origin"], dir).ok) { return { ok: false, area, reason: "fetch 失敗(連不上遠端)" }; } const branch = git(["rev-parse", "--abbrev-ref", "HEAD"], dir).stdout || "main"; const local = git(["rev-parse", "HEAD"], dir).stdout; const remote = git(["rev-parse", `origin/${branch}`], dir).stdout; // 再把工作副本疊上去,看看還有沒有沒推的差異 if (area === "wiki") { pl.writeText(path.join(dir, "Home.md"), wikiHome(slug, theCode)); pl.writeText(path.join(dir, "Icon.md"), wikiIconPage(slug, theCode)); } const files = stageArea(slug, area, dir); const dirty = git(["status", "--porcelain"], dir) // git() 會把輸出 trim 掉,所以 porcelain 開頭那個空白可能已經不見了(" M x" → "M x") .stdout.split("\n").map((l) => l.replace(/^\s*[A-Z?!]{1,2}\s+/, "").trim()).filter(Boolean); git(["checkout", "--", "."], dir); git(["clean", "-qfd"], dir); return { ok: Boolean(local) && local === remote && dirty.length === 0, area, code: theCode, files: files.length, local, remote, pending: dirty, }; } /** 形象圖(SVG + PNG)是不是真的在 Wiki 上、而且和本機一致。 */ export async function verifyIconInWiki(slug, opts = {}) { const res = await verifyArea(slug, "wiki", opts); if (res.skipped || !res.code) return res; const missing = ["icon.svg", "icon.png"].filter((f) => res.pending.includes(f)); const dir = syncDir(slug, "wiki"); const present = ["icon.svg", "icon.png"].filter((f) => fs.existsSync(path.join(dir, f))); return { ...res, icon_present: present, icon_pending: missing, ok: res.ok && present.length === 2 }; } /** 建立 Gitea 上的存取庫與 Wiki,並把兩區都推上去。 */ export async function initRemote(slug, { code = null, owner = null, private_ = true } = {}) { const problem = giteaProblem(); if (problem) throw new Error(problem); const theCode = code || personaCode(slug); if (!validCode(theCode)) throw new Error(`人格 \`${slug}\` 沒有合法編號(需 ASUNA-01 這種格式)。`); const theOwner = owner || (await resolveOwner()); const ident = pl.identityFields(slug); const { repo, created } = await ensureRepo(theOwner, theCode, { description: `jsc-persona 人格 ${theCode}${ident.Name ? `(${ident.Name})` : ""}`, private_, }); const wikiCreated = await ensureWikiHome(theOwner, theCode, wikiHome(slug, theCode)); const results = {}; for (const area of AREA_KEYS) { results[area] = await pushArea(slug, area, { code: theCode, owner: theOwner, message: `init(${area}): ${AREAS[area].why}`, }); } const state = loadSyncState(slug); state.code = theCode; state.owner = theOwner; state.repo_url = repo.html_url; state.initialized_at = state.initialized_at || pl.nowIso(); saveSyncState(slug, state); return { code: theCode, owner: theOwner, repo, created, wikiCreated, results }; } /** 背景 push(給 hook 用):不等結果、不阻斷這一輪。 */ export function pushInBackground(slug, area, sessionId, cliPath) { try { const child = spawn( process.execPath, [cliPath, "sync", "push", "--persona", slug, "--session", sessionId, "--area", area, "--if-due", "--quiet"], { detached: true, stdio: "ignore", env: process.env }, ); child.unref(); return true; } catch { return false; } }