feat(gitea): clone —— 從 Gitea 匯入一個本機還沒有的人格

底層本來就走得通(`pullArea` 的 restore 會把本機缺少的檔案全部補進來),
擋住的是上層的雞生蛋:`sync` 先走 `requireOwner`,而 `requireOwner` 第一件事
就是「本機沒有這個人格就 die」。本機沒有它 → load 不了它 → sync pull 被擋 →
永遠拉不回來。所以照 `import` 的模式另開一個只驗 session、不驗 host 的入口。

* `clone --code <編號>`:兩區都拉回來(Wiki 區給身分與長期記憶,檔案區給活狀態),
  然後補上 `pullArea` 不管的那幾件事——驗 IDENTITY.md(`validateBundle` 明文的
  人格最低要件,Gitea 這條路上原本不存在)、補寫 config.code/來歷、
  `rebuildIndex()`、`renderRelations()`。拉回來不成人格就中止並清掉半成品。
* `clone`(不帶 --code):列出遠端有哪些人格、哪些本機還沒有。
  整個 codebase 原本沒有任何「列出 owner 底下的存取庫」的呼叫,新增
  `listRemotePersonas()`:分頁打 `GET /user/repos`(他人/組織走 `/users/<owner>/repos`),
  用編號格式過濾——存取庫名稱就是人格編號,所以那份清單就是遠端的人格清單。
* 本機已有同名人格時**預設不覆蓋**;`--force` 才蓋(沿用 `import` 的兩道保護:
  不得覆寫別人、不得覆寫正被其他程序載入的人格),`--persona` 可並存兩份。
* 加進 `OWNER_EXEMPT_SUBCOMMANDS`,否則已載入其他人格時會被 hook deny。
* 編號衝突:`nextCode()` 只掃本機,換機器會重複發號。新增
  `nextCodeAcrossMachines()`,發號前先問遠端已經用掉哪些編號;Gitea 連不上
  就退回本機答案並在輸出明講「只對過本機」。`create` 與 `code assign/next` 都改用它。
* 順手修正 `ensureRepo` 的建庫路由:`me` 取自 `resolveOwner()`,而它在有
  `PERSONA_GITEA_OWNER` 時只會把那個值原封不動還回來,於是組織永遠走成
  `/user/repos`(建到 token 本人底下)。改用不受該環境變數影響的 `giteaLogin()`。

測試:selftest 新增第 ㉒ 區,用 file:// 的裸倉庫當「假的 Gitea」跑完整往返
(推兩區 → 刪掉本機人格 → clone 回來 → 驗身分/長期記憶/活狀態/索引/關係圖),
並涵蓋前兩個修正(子資料夾與非 ASCII 檔名真的進了存取庫、push 覆蓋遠端的回報
與 Stop hook 只吵一次)。345 → 373 項全過。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 09:35:29 +00:00
co-authored by Claude Opus 5
parent 5d27051293
commit 2b4b49d478
7 changed files with 509 additions and 23 deletions
+173 -10
View File
@@ -40,21 +40,47 @@ export function normalizeRomaji(romaji) {
export const codePrefix = (code) => String(code ?? "").split("-")[0] || "";
/** 掃全倉庫,回傳這個英文名下一個可用的編號(同名遞增,兩位數)。 */
export function nextCode(romaji) {
/**
* 掃全倉庫,回傳這個英文名下一個可用的編號(同名遞增,兩位數)。
* `taken` 可以補上「本機看不到但已經發出去的編號」(例如遠端 Gitea 上的存取庫)。
*/
export function nextCode(romaji, { taken = [] } = {}) {
const prefix = normalizeRomaji(romaji);
if (!prefix) return null;
let max = 0;
const consider = (candidate) => {
if (!validCode(candidate) || codePrefix(candidate) !== prefix) return;
max = Math.max(max, Number(candidate.split("-")[1]) || 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);
}
consider(pl.loadConfig(slug).code);
consider(slug);
}
for (const candidate of taken) consider(candidate);
if (max >= 99) return null;
return `${prefix}-${String(max + 1).padStart(2, "0")}`;
}
/**
* 跨機器的下一個編號。
* `nextCode` 只看得到本機,換一台機器就會把同一個號再發一次(兩個人格搶同一個存取庫)。
* 遠端的存取庫名稱正好就是「已經發出去的編號」,所以發號前先問遠端。
* Gitea 關掉或連不上時退回本機答案,並在 `checked_remote` 標明沒問成——不因為同步失敗就不給編號。
*/
export async function nextCodeAcrossMachines(romaji, { owner = null } = {}) {
const local = nextCode(romaji);
const problem = giteaProblem();
if (problem) return { code: local, checked_remote: false, reason: problem };
try {
const remote = await listRemotePersonas({ owner });
if (!remote.ok) return { code: local, checked_remote: false, reason: remote.reason };
const taken = remote.personas.map((p) => p.code);
return { code: nextCode(romaji, { taken }), checked_remote: true, taken };
} catch (err) {
return { code: local, checked_remote: false, reason: String(err.message || err) };
}
}
/** 這個人格的編號:config.code 優先,其次目錄名本身就是編號。 */
export function personaCode(slug) {
const code = pl.loadConfig(slug).code;
@@ -164,10 +190,9 @@ async function api(method, route, body = null) {
const ownerCachePath = () => path.join(pl.runtimeDir(), "gitea.json");
/** 存取庫的擁有者:`PERSONA_GITEA_OWNER` 優先,否則用 token 本人的帳號(會快取)。 */
export async function resolveOwner() {
/** token 本人的帳號(**不受** `PERSONA_GITEA_OWNER` 影響;會快取)。 */
export async function giteaLogin() {
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");
@@ -176,6 +201,62 @@ export async function resolveOwner() {
return res.json.login;
}
/** 存取庫的擁有者:`PERSONA_GITEA_OWNER` 優先,否則用 token 本人的帳號。 */
export async function resolveOwner() {
const env = giteaEnv();
if (env.owner) return env.owner;
return giteaLogin();
}
/** 分頁把 owner 底下的存取庫全部撈回來(Gitea 一頁上限 50)。 */
async function listRepos(owner, me) {
const route = owner === me ? "/user/repos" : `/users/${encodeURIComponent(owner)}/repos`;
const out = [];
for (let page = 1; page <= 40; page += 1) {
const res = await api("GET", `${route}?page=${page}&limit=50`);
if (!res.ok) throw new Error(`列出 ${owner} 的存取庫失敗(HTTP ${res.status}):${res.text.slice(0, 160)}`);
const batch = Array.isArray(res.json) ? res.json : [];
out.push(...batch);
if (batch.length < 50) break;
}
return out;
}
/** 本機已經用掉的編號 → 人格目錄名。 */
export function localCodes() {
const map = new Map();
for (const slug of pl.listPersonas()) {
const code = personaCode(slug);
if (code) map.set(code, slug);
}
return map;
}
/**
* Gitea 上有哪些人格。
* 存取庫名稱就是人格編號,所以「列出 owner 底下的存取庫再用編號格式過濾」
* 就是遠端的人格清單——換一台機器時,這是唯一能知道「有什麼可以拉」的方法。
*/
export async function listRemotePersonas({ owner = null } = {}) {
const problem = giteaProblem();
if (problem) return { ok: false, skipped: true, reason: problem, owner: null, personas: [] };
const me = await giteaLogin();
const theOwner = owner || (await resolveOwner());
const mine = localCodes();
const personas = (await listRepos(theOwner, me))
.filter((repo) => validCode(repo?.name) && (!repo.owner?.login || repo.owner.login === theOwner))
.map((repo) => ({
code: repo.name,
description: repo.description || "",
private: Boolean(repo.private),
html_url: repo.html_url || "",
updated_at: repo.updated_at || null,
local: mine.get(repo.name) || null,
}))
.sort((a, b) => a.code.localeCompare(b.code));
return { ok: true, owner: theOwner, personas };
}
export async function getRepo(owner, code) {
const res = await api("GET", `/repos/${owner}/${encodeURIComponent(code)}`);
return res.ok ? res.json : null;
@@ -186,7 +267,9 @@ export async function ensureRepo(owner, code, { description = "", private_ = tru
const existing = await getRepo(owner, code);
if (existing) return { repo: existing, created: false };
const env = giteaEnv();
const me = await resolveOwner();
// 建庫的路由要看「owner 是不是 token 本人」,不能拿 resolveOwner()
// (它在有 PERSONA_GITEA_OWNER 時只會把那個值原封不動還回來,組織就永遠走成 /user/repos
const me = await giteaLogin();
const route = owner === me ? "/user/repos" : `/orgs/${owner}/repos`;
const res = await api("POST", route, {
name: code,
@@ -770,6 +853,86 @@ export async function verifyIconInWiki(slug, opts = {}) {
return { ...res, icon_present: present, icon_pending: missing, ok: res.ok && present.length === 2 };
}
/**
* 把一個**本機還沒有**的人格從 Gitea 整個拉回來。
*
* 兩區加起來就是一個完整的人格:Wiki 區帶回身分與長期結構(IDENTITY/SOUL/長期記憶/
* 心智圖/關係圖),檔案區帶回活狀態(編號、情緒、短期記憶、逐字)。沒收的只有執行期狀態
* lockguestssleeperssync),那本來就該由這台機器自己產生。
*
* `pullArea` 只管檔案搬運,不管「拉回來的到底是不是一個人格」,所以這裡要補上
* `validateBundle` 那條最低要件(IDENTITY.md)與 `importBundle` 的收尾(config/索引/關係圖)。
*/
export async function importFromRemote(code, { owner = null, slug = null, force = false } = {}) {
const problem = giteaProblem();
if (problem) throw new Error(problem);
if (!validCode(code)) throw new Error(`編號 \`${code}\` 不合法(格式:ASUNA-01)。`);
const target = slug || code;
if (!pl.validSlug(target)) throw new Error(`人格目錄名 \`${target}\` 不合法(英數與連字號,最長 48 字)。`);
if (pl.personaExists(target) && !force) {
throw new Error(`本機已經有人格 \`${target}\`。要以遠端覆蓋本機請加 --force,或用 --persona <別的目錄名> 拉成另一份。`);
}
const theOwner = owner || (await resolveOwner());
// API 問得到就先確認存取庫真的存在(錯的編號要在動硬碟之前就擋下來);
// API 連不上時不擋——讓 git 的結果說話,離線/自架環境照樣拉得動。
let repo = null;
let apiUp = true;
try {
repo = await getRepo(theOwner, code);
} catch {
apiUp = false;
}
if (apiUp && !repo) {
throw new Error(`Gitea 上沒有 ${theOwner}/${code}(不帶 --code 可以列出有哪些人格)。`);
}
const fresh = !fs.existsSync(pl.personaDir(target));
pl.ensurePersonaDirs(target);
const results = {};
for (const area of AREA_KEYS) {
try {
// 本機是空的,衝突判定沒有意義;覆蓋既有人格時使用者已經明講了 --force
results[area] = await pullArea(target, area, { code, owner: theOwner, force: true });
} catch (err) {
results[area] = { ok: false, area, reason: String(err.message || err) };
}
}
if (!pl.personaExists(target)) {
// 半個人格比沒有人格更糟:清掉自己建的東西,並說清楚兩區各自發生什麼事
if (fresh) fs.rmSync(pl.personaDir(target), { recursive: true, force: true });
const detail = AREA_KEYS
.map((key) => `${AREAS[key].label}${results[key]?.ok
? `${results[key].written?.length ?? 0} 個檔案`
: String(results[key]?.reason || "失敗").slice(0, 80)}`)
.join("");
throw new Error(
`${theOwner}/${code} 拉回來的內容沒有 IDENTITY.md(人格的最低要件),已中止${fresh ? "並清掉半成品" : ""}${detail}`,
);
}
// 收尾:編號與來歷寫進 config,索引與關係圖重建(跟 importBundle 一樣)
const config = pl.loadConfig(target);
config.persona = target;
config.code = code;
config.romaji = codePrefix(code);
config.schema = config.schema || 2;
config.imported_at = pl.nowIso();
config.imported_from = { gitea: `${theOwner}/${code}`, repo_url: repo?.html_url || null };
pl.writeJson(pl.configPath(target), config);
pl.rebuildIndex(target);
try {
pl.renderRelations(target);
} catch {
/* 沒有關係圖就算了 */
}
const state = loadSyncState(target);
state.code = code;
state.owner = theOwner;
if (repo?.html_url) state.repo_url = repo.html_url;
state.imported_at = pl.nowIso();
saveSyncState(target, state);
const written = [...new Set(AREA_KEYS.flatMap((key) => results[key]?.written || []))].sort();
return { persona: target, code, owner: theOwner, repo, results, written, overwrote_local: !fresh };
}
/** 建立 Gitea 上的存取庫與 Wiki,並把兩區都推上去。 */
export async function initRemote(slug, { code = null, owner = null, private_ = true } = {}) {
const problem = giteaProblem();