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:
+173
-10
@@ -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/長期記憶/
|
||||
* 心智圖/關係圖),檔案區帶回活狀態(編號、情緒、短期記憶、逐字)。沒收的只有執行期狀態
|
||||
* (lock/guests/sleepers/sync),那本來就該由這台機器自己產生。
|
||||
*
|
||||
* `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();
|
||||
|
||||
Reference in New Issue
Block a user