diff --git a/CHANGELOG.md b/CHANGELOG.md index a8956ba..d749521 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,22 @@ --- +## 下一版(未發佈) + +- 🔴 **移除資料夾時,可以順便把我們放進去的隱藏檔案一起清掉**:小幫手整理你的資料夾時,會在**每一層**放一個隱藏的整理稿目錄(`.wiki`/`.arcrun-rag`)。以前你把資料夾從清單移除之後,這些東西**全部留在原地**——隱藏的、散在幾十層裡,你自己幾乎刪不乾淨。現在移除的對話框多了一個勾選框「順便把 Arcrun RAG 放在這個資料夾裡的檔案清掉」,勾起來會**先把確切要刪哪些列給你看**,你看過再按確定。 +- **只刪我們自己建的,你的檔案一個都不會碰**:每一筆都要有我們自己寫下的依據(我們放的說明檔、我們的整理帳本、我們的檔名前綴)才會刪;只要有一個東西認不出來,那整個目錄就原封不動留著,並且在清單上告訴你留了什麼、為什麼留。 +- **這幾種情況我們刻意不刪**:你那個資料夾裡如果還有別的資料夾也在同步清單裡,它底下的東西不碰;以前從你資料夾搬進暫存區的檔案不碰(那些是你的);捷徑(symlink)不碰。 +- 不勾這個框的話,行為跟以前一模一樣:硬碟上一個檔都不會少。 + +## 0.18.35(2026-08-24) + +- 🔴 **桌面小幫手多了「App 界面」:一打開就是九宮格,點一下就用得到**:以前小幫手只做一件事——把資料夾整理進知識庫;要用知識庫上的 App(例如寫一則筆記),得另外開瀏覽器登入雲端。現在小幫手最上面多了一頁「App 界面」,裡面是**你連的那個知識庫實際裝了的 App**,一個 icon 一個 App,點下去就在小幫手裡打開,不用再開瀏覽器。 +- 這批 App **跟你在知識庫網頁上看到的是同一批**:清單直接跟知識庫要,桌面上不另存一份,所以你在任何一台電腦看到的都一樣;在雲端裝了新 App,回到這裡按「重新整理」就會出現。 +- 沒有自己畫面的 App 也點得開:會列出它能做的事,一條一顆「現在執行」。 +- 連了不只一個知識庫的話,「App 界面」上方可以切換要看哪一個。 +- 還沒連上知識庫、或這個知識庫一個 App 都沒裝,畫面會直接說是哪一種情況,不會只給你一片空白。 +- **原本的功能一項都沒少**:同步狀態與「立刻同步」搬到了每一頁都看得到的最上方,首頁、各知識庫、AI 設定、版本與更新都在側邊欄原來的位置。 + ## 0.18.34(2026-08-20) - 🔴 **雲端現在看得到你資料夾的完整層次,不再只有第一層**:以前不管你的資料夾疊了幾層,雲端只認得最外面那一層——`專案/教育部標案/會議紀錄` 這種放在深處的資料夾,在知識庫裡完全不存在,只會以檔案路徑的一段文字出現。現在**每一層資料夾都有自己的一格**,而且**每一格都連著它的上一層**,所以你可以像在檔案總管裡一樣,從最外層一路點進最深的那個資料夾。每一格還會告訴你這一層直接放了幾份文件、連同底下的子資料夾一共幾份。 diff --git a/cleanup.go b/cleanup.go new file mode 100644 index 0000000..1120b55 --- /dev/null +++ b/cleanup.go @@ -0,0 +1,543 @@ +// cleanup.go — 斷連時把 daemon 自己寫進使用者資料夾的東西收回來 +// (arcrun-rag#138,leo 2026-08-24:「碎型會在每個資料夾安裝隱藏資料夾,人工刪除不容易, +// 所以當它斷連,應該要可以幫它把 Arcrun RAG 建立的資料夾刪掉」)。 +// +// 🔴 這是本 repo 唯一一支會**刪使用者資料夾裡的檔**的程式碼。tidy.go 的紅線是 +// 「一行 os.Remove 都沒有」——那條紅線在這裡不成立(使用者要的就是刪掉), +// 所以改用另一組更嚴的規矩: +// +// ① **只刪帳面上認得出來的東西。** 不靠「路徑看起來像我們的」「副檔名是 .md」 +// 這類推測——每一筆都要說得出一句**我們自己寫下的證據**(見 CleanupItem.Evidence)。 +// ② **一個認不出來的鄰居,就讓整個目錄留下。** `.wiki/` 裡只要有一個檔不在帳本上、 +// 也沒有標記,就從「刪整個目錄」降級成「只刪認得出來的那幾個檔」,目錄本身留著。 +// ⇒ 誤留一點殘渣是可接受的;誤刪使用者的知識不是。 +// ③ **先看清單再動手。** PlanCleanup 只讀不寫,ApplyCleanup 動手前**重算一次**計畫 +// (不吃呼叫端傳回來的舊清單)——畫面上看到的是快照,刪的當下才是真相。 +// +// 認人的三種證據(全部是 daemon 自己寫下去的字,不是猜的): +// +// E1 `.arcrun-rag/` 工作區:目錄裡的 `.gitignore` 內容**逐字**等於 workspaceIgnoreBody +// (repoguard.go 寫的那份,自己寫著「整個資料夾可以安全刪除」)。 +// E2 `.wiki/` 卡片目錄:目錄裡的 `.gitignore` 內容逐字等於 wikiIgnoreBody +// (wikishape.go 寫的,自己寫著「不是你的檔案」),或目錄裡有解析得開的 +// `manifest.json`(我們的 wiki 帳本)。 +// E3 `system-dev/wiki/cards/` 底下帶 MachineMark(`arcrun-`)前綴的檔 +// ——machinemark.go 的規約:daemon 在監看根底下產生的每個檔名都帶這個前綴。 +// +// 🔴 三個**刻意不刪**的東西(每一個都是實查出來的,不是保守而已): +// +// ① `.arcrun-rag/legacy-template/`:那是 tidy.go 的**收容處**,裡面裝的是從使用者 +// 資料夾**搬進來的檔**。tidy 自己說得很清楚:「TemplateOwns 是路徑身分判準, +// 分不出『daemon 鋪的』還是『repo 本來就有的』」⇒ 那些檔的所有權我們判不了。 +// 而 `.arcrun-rag/.gitignore` 上「整個資料夾可以安全刪除」那句話是 #105 寫的, +// 當時工作區裡還沒有收容處。**不把那句話套到收容處上**(#138 紅線第三條問的正是這題)。 +// ② 另一個**還在監看清單裡**的資料夾底下的東西:實務上真的會巢狀 +// (2026-08-24 現場:`pms` 與 `pms/pms_v1_legacy` 同時在看守)。移除外層時把內層的 +// 工作區刪掉=把一個還在跑的同步弄壞。 +// ③ 任何 symlink:不跟隨、不刪。刪一條指出去的連結會刪到監看根以外的東西。 +package collector + +import ( + "encoding/json" + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// 產物分類(給畫面分組用,也是 Apply 的處理方式)。 +const ( + CleanupKindWorkspace = "workspace" // `.arcrun-rag/` 工作區(整個目錄) + CleanupKindWorkFile = "work-file" // 工作區裡的單一項目(收容處存在時的降級路徑) + CleanupKindWikiDir = "wiki-dir" // `.wiki/` 卡片目錄(整個目錄) + CleanupKindWikiFile = "wiki-file" // `.wiki/` 裡的單一產物(有陌生鄰居時的降級路徑) + CleanupKindMarked = "marked-card" // 帶 arcrun- 標記的卡片(非隱藏的卡片產物區) +) + +// CleanupItem=一筆「打算刪掉」的東西。Evidence 是白話的認人依據,會直接顯示給使用者看 +// ——沒有 Evidence 的東西不准進這個清單。 +type CleanupItem struct { + Rel string `json:"rel"` // 相對監看根,斜線分隔 + IsDir bool `json:"is_dir"` + Kind string `json:"kind"` + Evidence string `json:"evidence"` + Files int `json:"files"` // 這一筆底下有幾個檔(目錄才 > 1) + Bytes int64 `json:"bytes"` +} + +// CleanupKeep=一筆「認得出來但刻意不刪」或「認不出來所以不敢刪」的東西。 +// 這份清單和 Remove 一樣要給使用者看——沉默地留下殘渣,跟沉默地刪掉一樣糟。 +type CleanupKeep struct { + Rel string `json:"rel"` + Reason string `json:"reason"` +} + +// CleanupPlan=一次清理的完整帳目。PlanCleanup 只讀不寫,這份東西就是「動手前的清單」。 +type CleanupPlan struct { + Root string `json:"root"` + Remove []CleanupItem `json:"remove"` + Keep []CleanupKeep `json:"keep"` + Files int `json:"files"` // Remove 涵蓋的檔案總數 + Bytes int64 `json:"bytes"` +} + +// CleanupFailure=刪不掉的那一筆(權限、檔案正被開著…)。 +type CleanupFailure struct { + Rel string `json:"rel"` + Error string `json:"error"` +} + +// CleanupResult=實際刪掉了什麼。 +type CleanupResult struct { + Removed []string `json:"removed"` + Failed []CleanupFailure `json:"failed"` + Files int `json:"files"` + Bytes int64 `json:"bytes"` +} + +var cleanupBakRe = regexp.MustCompile(`^(.*)\.bak-\d+$`) + +// WorkspaceIgnoreBodyForTest/WikiIgnoreBodyForTest 讓**別的 package 的迴歸網** +// (cmd/arcrun-app)造得出「與 daemon 真正寫下去一字不差」的宣告檔。 +// +// 為什麼不讓測試自己抄一份字串:抄的那份會漂。認人的判準是「逐字相等」, +// 判準與樣本一旦不同源,這兩份 .gitignore 改一個字就會有一整層測試靜默失效。 +// 名字帶 ForTest 是說給讀 code 的人聽的——**production 路徑不准依賴它們**。 +func WorkspaceIgnoreBodyForTest() string { return workspaceIgnoreBody } +func WikiIgnoreBodyForTest() string { return wikiIgnoreBody } + +// PlanCleanup 列出「把 root 從監看清單移除時,可以連帶收掉的 daemon 產物」。 +// +// **這支函式不寫任何檔案。** otherRoots=其他仍在監看清單裡的資料夾(絕對路徑); +// 落在它們底下的東西一律不碰(見檔頭不刪清單②)。 +func PlanCleanup(root string, otherRoots []string) (*CleanupPlan, error) { + absRoot, err := filepath.Abs(root) + if err != nil { + return nil, err + } + absRoot = filepath.Clean(absRoot) + if st, serr := os.Lstat(absRoot); serr != nil { + return nil, serr + } else if !st.IsDir() { + return nil, &fs.PathError{Op: "cleanup", Path: absRoot, Err: fs.ErrInvalid} + } + + // 只留「真的在 absRoot 底下」的其他根——不在底下的本來就走不到,留著只會拖慢比對。 + var others []string + for _, o := range otherRoots { + a, oerr := filepath.Abs(o) + if oerr != nil { + continue + } + a = filepath.Clean(a) + if a != absRoot && pathWithin(absRoot, a) { + others = append(others, a) + } + } + + p := &cleanupPlanner{ + root: absRoot, + others: others, + cards: collectWikiCardIndex(absRoot, others), + plan: &CleanupPlan{Root: absRoot}, + } + p.walk(absRoot) + sort.Slice(p.plan.Remove, func(i, j int) bool { return p.plan.Remove[i].Rel < p.plan.Remove[j].Rel }) + sort.Slice(p.plan.Keep, func(i, j int) bool { return p.plan.Keep[i].Rel < p.plan.Keep[j].Rel }) + return p.plan, nil +} + +// ApplyCleanup 真的動手刪。 +// +// 🔴 它**重算一次計畫**,不接受呼叫端傳進來的舊清單——使用者看清單、按確定之間可能過了 +// 幾秒或幾分鐘,那段時間裡他可能剛好把一個自己的檔存進 `.wiki/`。畫面上那份是快照, +// 刪的當下重算的這份才是判準。回傳的 plan 就是實際執行的那一份(呼叫端要顯示就用它)。 +func ApplyCleanup(root string, otherRoots []string) (*CleanupPlan, *CleanupResult, error) { + plan, err := PlanCleanup(root, otherRoots) + if err != nil { + return nil, nil, err + } + res := &CleanupResult{} + // 由深到淺刪:先刪目錄裡的東西,再刪目錄自己(降級路徑會同時出現父目錄底下的多筆)。 + items := append([]CleanupItem(nil), plan.Remove...) + sort.Slice(items, func(i, j int) bool { return len(items[i].Rel) > len(items[j].Rel) }) + for _, it := range items { + abs := filepath.Join(plan.Root, filepath.FromSlash(it.Rel)) + // 最後一道機械閘:不管計畫怎麼算的,目標一定要在監看根底下,而且不是 symlink。 + if !pathWithin(plan.Root, abs) { + res.Failed = append(res.Failed, CleanupFailure{Rel: it.Rel, Error: "路徑不在這個資料夾底下,沒有刪"}) + continue + } + st, serr := os.Lstat(abs) + if serr != nil { + if os.IsNotExist(serr) { + continue // 已經不在了(重複按、或上一輪刪過)=不是失敗 + } + res.Failed = append(res.Failed, CleanupFailure{Rel: it.Rel, Error: serr.Error()}) + continue + } + if st.Mode()&os.ModeSymlink != 0 { + res.Failed = append(res.Failed, CleanupFailure{Rel: it.Rel, Error: "這是一條捷徑(symlink),沒有刪"}) + continue + } + var derr error + if it.IsDir { + derr = os.RemoveAll(abs) + } else { + derr = os.Remove(abs) + } + if derr != nil { + res.Failed = append(res.Failed, CleanupFailure{Rel: it.Rel, Error: derr.Error()}) + continue + } + res.Removed = append(res.Removed, it.Rel) + res.Files += it.Files + res.Bytes += it.Bytes + } + sort.Strings(res.Removed) + return plan, res, nil +} + +// ── 以下是實作細節 ──────────────────────────────────────────────── + +type cleanupPlanner struct { + root string + others []string + cards map[string]bool // 帳本上每張卡的絕對路徑 + plan *CleanupPlan +} + +func (p *cleanupPlanner) rel(abs string) string { + r, err := filepath.Rel(p.root, abs) + if err != nil { + return filepath.ToSlash(abs) + } + return filepath.ToSlash(r) +} + +func (p *cleanupPlanner) remove(abs, kind, evidence string, isDir bool) { + files, bytes := countTree(abs) + p.plan.Remove = append(p.plan.Remove, CleanupItem{ + Rel: p.rel(abs), IsDir: isDir, Kind: kind, Evidence: evidence, Files: files, Bytes: bytes, + }) + p.plan.Files += files + p.plan.Bytes += bytes +} + +func (p *cleanupPlanner) keep(abs, reason string) { + p.plan.Keep = append(p.plan.Keep, CleanupKeep{Rel: p.rel(abs), Reason: reason}) +} + +// walk 走整棵樹。刻意**不用 filepath.WalkDir**:我們要在「進不進去某個目錄」這件事上 +// 完全自己說了算(其他隱藏目錄整棵跳過、別的監看根整棵跳過、symlink 不跟隨)。 +func (p *cleanupPlanner) walk(dir string) { + entries, err := os.ReadDir(dir) + if err != nil { + return + } + for _, e := range entries { + abs := filepath.Join(dir, e.Name()) + if e.Type()&os.ModeSymlink != 0 { + continue // 捷徑一律不碰(可能指到監看根以外) + } + if !e.IsDir() { + continue // 目錄以外的檔,只有在下面那幾個已認定的目錄裡才會被處理 + } + if p.underOtherRoot(abs) { + p.keep(abs, "這個資料夾也還在同步清單裡,它底下的東西留給它自己管") + continue + } + switch e.Name() { + case workspaceRelDir: + p.planWorkspace(abs) + continue + case wikiRelDir: + p.planWikiDir(abs) + continue + } + if strings.HasPrefix(e.Name(), ".") { + continue // 其他隱藏目錄整棵跳過——daemon 的 Scan 也不進去,那裡不會有我們的東西 + } + if p.rel(abs) == cardsRelDir { + p.planMarkedCards(abs) + continue + } + p.walk(abs) + } +} + +func (p *cleanupPlanner) underOtherRoot(abs string) bool { + for _, o := range p.others { + if abs == o || pathWithin(o, abs) { + return true + } + } + return false +} + +// planWorkspace 處理 `.arcrun-rag/`。 +func (p *cleanupPlanner) planWorkspace(abs string) { + if !fileHasExactBody(filepath.Join(abs, ".gitignore"), workspaceIgnoreBody) { + p.keep(abs, "這個 .arcrun-rag 目錄裡沒有我們寫的 .gitignore 宣告,認不出是不是我們建的,所以不動它") + return + } + const ev = "目錄裡的 .gitignore 逐字等於我們寫的那份(自己寫著「整個資料夾可以安全刪除」)" + + quarantine := filepath.Join(abs, "legacy-template") + if st, err := os.Lstat(quarantine); err == nil && st.IsDir() && !isEmptyDir(quarantine) { + // 收容處裡是「從你的資料夾搬進來的檔」⇒ 整個目錄不能一次刪,改成逐項刪、留下收容處。 + p.keep(quarantine, "這裡面是以前從你的資料夾搬進來的檔案(不是我們產生的),所有權判不了,一律留著讓你自己處理") + entries, err := os.ReadDir(abs) + if err != nil { + return + } + for _, e := range entries { + if e.Name() == "legacy-template" { + continue + } + child := filepath.Join(abs, e.Name()) + if e.Type()&os.ModeSymlink != 0 { + p.keep(child, "這是一條捷徑(symlink),不碰") + continue + } + p.remove(child, CleanupKindWorkFile, ev, e.IsDir()) + } + return + } + p.remove(abs, CleanupKindWorkspace, ev, true) +} + +// planWikiDir 處理一個 `.wiki/`。 +// +// 兩步:① 這個目錄是不是我們的(要有我們寫下的宣告)② 裡面**每一個**東西是不是都認得。 +// 第二步是這支函式的重點——認得全部才刪整個目錄,有一個認不得就降級成逐檔刪。 +func (p *cleanupPlanner) planWikiDir(abs string) { + evidence := "" + if fileHasExactBody(filepath.Join(abs, ".gitignore"), wikiIgnoreBody) { + evidence = "目錄裡的 .gitignore 逐字等於我們寫的那份(自己寫著「這個資料夾是 Arcrun RAG 產生的 wiki,不是你的檔案」)" + } else if isOurWikiManifest(filepath.Join(abs, "manifest.json")) { + evidence = "目錄裡有我們寫的 manifest.json(wiki 帳本,記著每張卡對應哪份原稿)" + } else { + p.keep(abs, "這個 .wiki 目錄裡既沒有我們寫的 .gitignore、也沒有我們的 manifest.json,認不出是不是我們建的,所以不動它") + return + } + + entries, err := os.ReadDir(abs) + if err != nil { + p.keep(abs, "讀不到這個目錄的內容("+err.Error()+"),所以不動它") + return + } + type known struct { + name string + isDir bool + } + var ours []known + var strangers []string + accounted := map[string]bool{} + for _, e := range entries { + if p.wikiEntryIsOurs(abs, e) { + accounted[e.Name()] = true + } + } + for _, e := range entries { + if accounted[e.Name()] { + ours = append(ours, known{e.Name(), e.IsDir()}) + continue + } + // `.bak-` 是 safeWriteCard 的備份:本體認得,它就認得。 + if m := cleanupBakRe.FindStringSubmatch(e.Name()); m != nil && accounted[m[1]] { + ours = append(ours, known{e.Name(), e.IsDir()}) + continue + } + strangers = append(strangers, e.Name()) + } + + if len(strangers) == 0 { + p.remove(abs, CleanupKindWikiDir, evidence, true) + return + } + // 降級:只刪認得出來的那幾個,目錄與陌生鄰居原封不動。 + for _, k := range ours { + p.remove(filepath.Join(abs, k.name), CleanupKindWikiFile, evidence, k.isDir) + } + for _, s := range strangers { + p.keep(filepath.Join(abs, s), "這個檔不在我們的帳本上、也沒有 arcrun- 標記——可能是你自己放進來的,留著") + } +} + +// wikiEntryIsOurs 回答「`.wiki/` 裡的這一項是不是我們寫的」。四條路,全部要拿得出證據。 +func (p *cleanupPlanner) wikiEntryIsOurs(dir string, e os.DirEntry) bool { + if e.Type()&os.ModeSymlink != 0 { + return false // 捷徑一律不算我們的 + } + abs := filepath.Join(dir, e.Name()) + if e.IsDir() { + return false // 我們從來不在 .wiki/ 底下開子目錄 + } + switch { + case e.Name() == ".gitignore": + return fileHasExactBody(abs, wikiIgnoreBody) + case e.Name() == "manifest.json": + return isOurWikiManifest(abs) + case e.Name() == "00-INDEX.md": + return fileHasPrefix(abs, "# 00-INDEX") + case p.cards[abs]: + return true // 帳本上有這張卡 + case IsMarked(e.Name()): + return true // machinemark.go 的規約前綴 + } + return false +} + +// planMarkedCards 處理 `system-dev/wiki/cards/`——那是**看得見**的卡片產物區 +// (監看根不在筆記庫也不在版控時的落點)。這裡不刪目錄,只刪帶標記的檔: +// 那個目錄是 system-dev-template 的規約路徑,使用者自己的卡也住在那裡(#105 的教訓)。 +func (p *cleanupPlanner) planMarkedCards(abs string) { + _ = filepath.WalkDir(abs, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() || d.Type()&os.ModeSymlink != 0 { + return nil + } + if IsMarked(d.Name()) { + p.remove(path, CleanupKindMarked, "檔名帶 arcrun- 標記——daemon 產生的每個檔都帶這個前綴(machinemark.go 規約)", false) + } + return nil + }) +} + +// collectWikiCardIndex 把樹裡每一份 `.wiki/manifest.json` 讀出來,攤成「這些絕對路徑是我們的卡」。 +// +// 為什麼要掃整棵樹而不是只讀監看根那一份:實務上樹裡會有**別的監看根**留下的帳本 +// (`pms/pms_v1_legacy/.wiki/manifest.json`)。帳本裡的卡路徑是相對**它自己的根** +// (=那個 `.wiki` 的上層目錄),所以每份帳本各自解析。 +func collectWikiCardIndex(absRoot string, others []string) map[string]bool { + out := map[string]bool{} + var walk func(dir string) + walk = func(dir string) { + entries, err := os.ReadDir(dir) + if err != nil { + return + } + for _, e := range entries { + if !e.IsDir() || e.Type()&os.ModeSymlink != 0 { + continue + } + abs := filepath.Join(dir, e.Name()) + if e.Name() == wikiRelDir { + addManifestCards(out, abs) + continue + } + if strings.HasPrefix(e.Name(), ".") { + continue + } + walk(abs) + } + } + walk(absRoot) + // 別的監看根底下那份帳本也要讀——它記的卡可能落在**我們這一側**(節點在共同祖先下)。 + // 讀帳本是純讀取,不代表會刪那邊的東西(刪不刪由 underOtherRoot 決定)。 + for _, o := range others { + addManifestCards(out, filepath.Join(o, wikiRelDir)) + } + return out +} + +func addManifestCards(out map[string]bool, wikiDir string) { + path := filepath.Join(wikiDir, "manifest.json") + m, ok := readOurWikiManifest(path) + if !ok { + return + } + owner := filepath.Dir(wikiDir) // 帳本裡的路徑相對這一層 + for _, d := range m.Docs { + for _, c := range d.Cards { + c = strings.TrimSpace(c) + if c == "" || strings.Contains(c, "..") || filepath.IsAbs(filepath.FromSlash(c)) { + continue // 帳本被改壞時不讓它把我們指到樹外面 + } + out[filepath.Join(owner, filepath.FromSlash(c))] = true + } + } +} + +func readOurWikiManifest(path string) (*wikiManifest, bool) { + data, err := os.ReadFile(path) + if err != nil { + return nil, false + } + var m wikiManifest + if json.Unmarshal(data, &m) != nil { + return nil, false + } + // 認的是形狀,不是「檔名叫 manifest.json」:要有版本號,而且每筆 doc 都要有我們的鍵。 + if m.Version <= 0 { + return nil, false + } + for _, d := range m.Docs { + if d.Node == "" || d.DocID == "" { + return nil, false + } + } + return &m, true +} + +func isOurWikiManifest(path string) bool { + _, ok := readOurWikiManifest(path) + return ok +} + +// fileHasExactBody:檔案內容**逐字**等於 want。逐字比對是刻意的——「開頭像」不夠, +// 有人可能在我們的宣告後面接自己的規則,那份 .gitignore 就不只是我們的了。 +func fileHasExactBody(path, want string) bool { + data, err := os.ReadFile(path) + return err == nil && string(data) == want +} + +func fileHasPrefix(path, want string) bool { + f, err := os.Open(path) + if err != nil { + return false + } + defer f.Close() + buf := make([]byte, len(want)) + n, _ := f.Read(buf) + return n == len(want) && string(buf) == want +} + +// pathWithin 回答 child 是不是 parent 底下(不含 parent 自己)。純字串比對, +// 呼叫端傳進來的都已經是 Abs+Clean 過的路徑。 +func pathWithin(parent, child string) bool { + rel, err := filepath.Rel(parent, child) + if err != nil { + return false + } + return rel != "." && !strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel) +} + +func isEmptyDir(dir string) bool { + entries, err := os.ReadDir(dir) + return err == nil && len(entries) == 0 +} + +// countTree 算一筆刪除目標涵蓋幾個檔、多少位元組(給清單顯示「會刪掉 N 個檔」)。 +func countTree(abs string) (files int, bytes int64) { + st, err := os.Lstat(abs) + if err != nil { + return 0, 0 + } + if !st.IsDir() { + return 1, st.Size() + } + _ = filepath.WalkDir(abs, func(_ string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + if info, ierr := d.Info(); ierr == nil { + files++ + bytes += info.Size() + } + return nil + }) + return files, bytes +} diff --git a/cleanup_cli.go b/cleanup_cli.go new file mode 100644 index 0000000..b14f4ae --- /dev/null +++ b/cleanup_cli.go @@ -0,0 +1,95 @@ +// cleanup_cli.go — `collector cleanup` 子命令(arcrun-rag#138)。 +// +// 為什麼 App 之外還要有一支 CLI:#138 的驗收條件是「使用者要能在動手前看到將要刪掉 +// 哪些東西」。畫面上那份清單與這支印出來的是**同一個 PlanCleanup**—— +// 一份判斷邏輯兩個出口,不會出現「畫面說會刪 A、實際刪了 B」。 +// 而且出事時(畫面按鈕壞掉、使用者不信任 GUI)他仍然有一條看得見全部細節的路。 +package collector + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +// multiFlag 讓 --keep 可以重複給(其他還在看守的資料夾)。 +type multiFlag []string + +func (m *multiFlag) String() string { return strings.Join(*m, ",") } +func (m *multiFlag) Set(v string) error { *m = append(*m, v); return nil } + +func runCleanup(args []string) int { + fs2 := newFlagSet() + folder := fs2.String("folder", "", "要斷連的資料夾(必填)") + apply := fs2.Bool("apply", false, "真的刪(不加=只列清單,什麼都不動)") + asJSON := fs2.Bool("json", false, "輸出 JSON(給程式讀)") + var keep multiFlag + fs2.Var(&keep, "keep", "其他還在看守的資料夾(可重複給)——它們底下的東西一律不碰") + if err := fs2.Parse(args); err != nil { + return 2 + } + if *folder == "" { + fmt.Fprintln(os.Stderr, "錯誤:--folder 為必填") + return 2 + } + + var plan *CleanupPlan + var res *CleanupResult + var err error + if *apply { + plan, res, err = ApplyCleanup(*folder, keep) + } else { + plan, err = PlanCleanup(*folder, keep) + } + if err != nil { + fmt.Fprintln(os.Stderr, "collector cleanup:", err) + return 1 + } + + if *asJSON { + out := struct { + Plan *CleanupPlan `json:"plan"` + Result *CleanupResult `json:"result,omitempty"` + }{plan, res} + data, _ := json.MarshalIndent(out, "", " ") + fmt.Println(string(data)) + if res != nil && len(res.Failed) > 0 { + return 1 + } + return 0 + } + + fmt.Println(plan.Root) + if len(plan.Remove) == 0 { + fmt.Println("沒有找到任何 Arcrun RAG 建立的東西——這個資料夾不需要清理。") + } else { + if !*apply { + fmt.Println("※ 這只是清單,什麼都還沒刪。確認沒問題後,同一行指令加上 --apply 才會真的刪。") + } + fmt.Printf("會刪掉 %d 項(共 %d 個檔):\n", len(plan.Remove), plan.Files) + for _, it := range plan.Remove { + mark := "檔" + if it.IsDir { + mark = "目錄" + } + fmt.Printf(" 刪 %s %s(%d 個檔)\n 依據:%s\n", mark, it.Rel, it.Files, it.Evidence) + } + } + if len(plan.Keep) > 0 { + fmt.Printf("留著不動 %d 項:\n", len(plan.Keep)) + for _, k := range plan.Keep { + fmt.Printf(" 留 %s\n 原因:%s\n", k.Rel, k.Reason) + } + } + if res != nil { + fmt.Printf("實際刪掉 %d 項 / %d 個檔。\n", len(res.Removed), res.Files) + for _, f := range res.Failed { + fmt.Printf(" ✗ 刪不掉 %s(%s)\n", f.Rel, f.Error) + } + if len(res.Failed) > 0 { + return 1 + } + } + return 0 +} diff --git a/cleanup_test.go b/cleanup_test.go new file mode 100644 index 0000000..5dac8c1 --- /dev/null +++ b/cleanup_test.go @@ -0,0 +1,429 @@ +// cleanup_test.go — 斷連清理的迴歸網(arcrun-rag#138)。 +// +// 這支測的重點**不是「有沒有刪掉」**,是「**有沒有刪到不該刪的**」。 +// 每一個 case 都對應一種「差一點就誤刪使用者知識」的擺法。 +package collector + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +func cuWriteFile(t *testing.T, path, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +// makeWiki 造一個「長得跟 daemon 產的一模一樣」的 .wiki 目錄。 +func makeWiki(t *testing.T, dir string, cards ...string) { + t.Helper() + cuWriteFile(t, filepath.Join(dir, ".gitignore"), wikiIgnoreBody) + cuWriteFile(t, filepath.Join(dir, "00-INDEX.md"), "# 00-INDEX\n\n## 文件\n") + for _, c := range cards { + cuWriteFile(t, filepath.Join(dir, c), "# "+strings.TrimSuffix(c, ".md")+"\n") + } +} + +// makeRootManifest 寫出監看根那一份 `.wiki/manifest.json`(帳本)。 +// nodeCards=節點 →「相對監看根」的卡片路徑,跟 daemon 實際寫的形狀一致 +// (2026-08-24 對照 `pms` 現場的真檔驗過:帳本涵蓋每一張卡,只有 00-INDEX.md 不在裡面)。 +func makeRootManifest(t *testing.T, root string, nodeCards map[string][]string) { + t.Helper() + m := &wikiManifest{Version: 1} + for node, cards := range nodeCards { + m.Docs = append(m.Docs, wikiDoc{ + Node: nodeKeyOf(node), Path: "來源.md", DocID: "doc-" + node + "-1", + Status: "extracted", Cards: cards, + }) + } + data, err := json.MarshalIndent(m, "", " ") + if err != nil { + t.Fatal(err) + } + cuWriteFile(t, filepath.Join(root, wikiRelDir, "manifest.json"), string(data)+"\n") +} + +func makeWorkspace(t *testing.T, root string) { + t.Helper() + cuWriteFile(t, filepath.Join(root, workspaceRelDir, ".gitignore"), workspaceIgnoreBody) + cuWriteFile(t, filepath.Join(root, workspaceRelDir, "wiki", "cards", "arcrun-資料夾總覽-x.md"), "# x\n") +} + +func relsOf(items []CleanupItem) []string { + out := make([]string, 0, len(items)) + for _, it := range items { + out = append(out, it.Rel) + } + sort.Strings(out) + return out +} + +func keepRels(keeps []CleanupKeep) []string { + out := make([]string, 0, len(keeps)) + for _, k := range keeps { + out = append(out, k.Rel) + } + sort.Strings(out) + return out +} + +// snapshotTree 記下整棵樹每個檔的相對路徑與內容雜湊——用來證明「使用者的檔一個都沒動」。 +func cuSnapshotTree(t *testing.T, root string) map[string]string { + t.Helper() + out := map[string]string{} + err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + rel, _ := filepath.Rel(root, p) + data, rerr := os.ReadFile(p) + if rerr != nil { + return nil + } + out[filepath.ToSlash(rel)] = string(data) + return nil + }) + if err != nil { + t.Fatal(err) + } + return out +} + +// ── ① 基本盤:多層巢狀的 .wiki 與工作區全部收掉,使用者的原稿一個不少 ── + +func TestCleanupRemovesNestedProductsKeepsUserFiles(t *testing.T) { + root := t.TempDir() + // 使用者的東西 + cuWriteFile(t, filepath.Join(root, "README.md"), "我的說明\n") + cuWriteFile(t, filepath.Join(root, "docs", "設計.md"), "我的設計\n") + cuWriteFile(t, filepath.Join(root, "docs", "deep", "更深的.md"), "我的深層檔\n") + // daemon 的東西(三層) + makeWorkspace(t, root) + makeWiki(t, filepath.Join(root, wikiRelDir), "根卡.md") + makeWiki(t, filepath.Join(root, "docs", wikiRelDir), "設計卡.md") + makeWiki(t, filepath.Join(root, "docs", "deep", wikiRelDir), "深卡.md") + makeRootManifest(t, root, map[string][]string{ + "": {".wiki/根卡.md"}, + "docs": {"docs/.wiki/設計卡.md"}, + "docs/deep": {"docs/deep/.wiki/深卡.md"}, + }) + + before := cuSnapshotTree(t, root) + + plan, res, err := ApplyCleanup(root, nil) + if err != nil { + t.Fatal(err) + } + if len(res.Failed) != 0 { + t.Fatalf("不該有刪不掉的:%+v", res.Failed) + } + want := []string{".arcrun-rag", ".wiki", "docs/.wiki", "docs/deep/.wiki"} + if got := relsOf(plan.Remove); !equalStrings(got, want) { + t.Fatalf("要刪的清單不對\n got=%v\nwant=%v", got, want) + } + + after := cuSnapshotTree(t, root) + // 使用者的檔:逐一比對「內容一字不差」。 + for _, rel := range []string{"README.md", "docs/設計.md", "docs/deep/更深的.md"} { + if after[rel] != before[rel] { + t.Fatalf("使用者的檔被動到了:%s(before=%q after=%q)", rel, before[rel], after[rel]) + } + } + // 剩下的檔**只能是**使用者那三個。 + if len(after) != 3 { + t.Fatalf("清完之後應該只剩使用者的 3 個檔,實際剩 %d:%v", len(after), after) + } + for _, hidden := range []string{".wiki", ".arcrun-rag", "docs/.wiki", "docs/deep/.wiki"} { + if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(hidden))); !os.IsNotExist(err) { + t.Fatalf("%s 沒被刪掉", hidden) + } + } +} + +// ── ② 陌生鄰居:.wiki 裡有一個不是我們的檔 ⇒ 整個目錄留下,只刪認得的 ── + +func TestCleanupKeepsWikiDirWithStrangerFile(t *testing.T) { + root := t.TempDir() + wiki := filepath.Join(root, wikiRelDir) + makeWiki(t, wiki, "卡.md") + makeRootManifest(t, root, map[string][]string{"": {".wiki/卡.md"}}) + cuWriteFile(t, filepath.Join(wiki, "我自己寫的.md"), "這是我的\n") + + plan, res, err := ApplyCleanup(root, nil) + if err != nil { + t.Fatal(err) + } + _ = res + for _, it := range plan.Remove { + if it.Kind == CleanupKindWikiDir { + t.Fatalf("有陌生鄰居時不准整個目錄刪掉:%+v", it) + } + } + if _, err := os.Stat(filepath.Join(wiki, "我自己寫的.md")); err != nil { + t.Fatalf("使用者自己的檔被刪了:%v", err) + } + if _, err := os.Stat(wiki); err != nil { + t.Fatalf(".wiki 目錄不該被刪:%v", err) + } + // 帳本以外的檔(00-INDEX 與 .gitignore 是我們的)該刪掉。 + if _, err := os.Stat(filepath.Join(wiki, "00-INDEX.md")); !os.IsNotExist(err) { + t.Fatalf("00-INDEX.md 應該被刪掉") + } + if !contains(keepRels(plan.Keep), ".wiki/我自己寫的.md") { + t.Fatalf("留下的東西要出現在 Keep 清單上讓使用者看到:%v", keepRels(plan.Keep)) + } +} + +// ── ③ 沒有我們宣告的 .wiki(使用者自己的同名目錄)⇒ 一個檔都不碰 ── + +func TestCleanupIgnoresForeignWikiDir(t *testing.T) { + root := t.TempDir() + wiki := filepath.Join(root, wikiRelDir) + cuWriteFile(t, filepath.Join(wiki, "我的筆記.md"), "我的\n") + cuWriteFile(t, filepath.Join(wiki, "00-INDEX.md"), "# 00-INDEX\n") // 連名字都一樣也不行 + + before := cuSnapshotTree(t, root) + plan, _, err := ApplyCleanup(root, nil) + if err != nil { + t.Fatal(err) + } + if len(plan.Remove) != 0 { + t.Fatalf("沒有我們的宣告就一個都不准刪:%v", relsOf(plan.Remove)) + } + if got := cuSnapshotTree(t, root); len(got) != len(before) { + t.Fatalf("樹被動到了:before=%v after=%v", before, got) + } +} + +// ── ④ .gitignore 被使用者改過(後面加了自己的規則)⇒ 不算我們的宣告 ── + +func TestCleanupRequiresExactIgnoreBody(t *testing.T) { + root := t.TempDir() + wiki := filepath.Join(root, wikiRelDir) + makeWiki(t, wiki, "卡.md") + cuWriteFile(t, filepath.Join(wiki, ".gitignore"), wikiIgnoreBody+"\n!我要收版控的.md\n") + + plan, err := PlanCleanup(root, nil) + if err != nil { + t.Fatal(err) + } + if len(plan.Remove) != 0 { + t.Fatalf(".gitignore 被改過就不該認:%v", relsOf(plan.Remove)) + } +} + +// ── ⑤ 工作區裡有 legacy-template 收容處 ⇒ 收容處留著,其餘刪 ── + +func TestCleanupKeepsLegacyTemplateQuarantine(t *testing.T) { + root := t.TempDir() + makeWorkspace(t, root) + quar := filepath.Join(root, legacyTemplateRelDir, "system-dev", "wiki", "status.md") + cuWriteFile(t, quar, "使用者的舊 status\n") + + plan, res, err := ApplyCleanup(root, nil) + if err != nil { + t.Fatal(err) + } + if len(res.Failed) != 0 { + t.Fatalf("不該有失敗:%+v", res.Failed) + } + if _, err := os.Stat(quar); err != nil { + t.Fatalf("收容處裡的檔被刪了——那是從使用者資料夾搬進來的:%v", err) + } + if _, err := os.Stat(filepath.Join(root, workspaceRelDir, "wiki")); !os.IsNotExist(err) { + t.Fatalf("工作區裡我們自己的東西該刪掉") + } + if !contains(keepRels(plan.Keep), ".arcrun-rag/legacy-template") { + t.Fatalf("收容處要出現在 Keep 清單:%v", keepRels(plan.Keep)) + } +} + +// ── ⑥ 另一個還在看守的巢狀根 ⇒ 它底下的東西一個都不碰(2026-08-24 pms 現場擺法)── + +func TestCleanupSkipsOtherWatchedRoot(t *testing.T) { + root := t.TempDir() + inner := filepath.Join(root, "legacy") + makeWorkspace(t, root) + makeWiki(t, filepath.Join(root, wikiRelDir), "外卡.md") + makeRootManifest(t, root, map[string][]string{"": {".wiki/外卡.md"}}) + makeWorkspace(t, inner) + makeWiki(t, filepath.Join(inner, wikiRelDir), "內卡.md") + makeWiki(t, filepath.Join(inner, "sub", wikiRelDir), "更內卡.md") + makeRootManifest(t, inner, map[string][]string{ + "": {".wiki/內卡.md"}, + "sub": {"sub/.wiki/更內卡.md"}, + }) + + plan, res, err := ApplyCleanup(root, []string{inner}) + if err != nil { + t.Fatal(err) + } + if len(res.Failed) != 0 { + t.Fatalf("不該有失敗:%+v", res.Failed) + } + for _, rel := range relsOf(plan.Remove) { + if strings.HasPrefix(rel, "legacy/") { + t.Fatalf("動到另一個還在看守的根:%s", rel) + } + } + for _, p := range []string{ + filepath.Join(inner, workspaceRelDir, ".gitignore"), + filepath.Join(inner, wikiRelDir, "內卡.md"), + filepath.Join(inner, "sub", wikiRelDir, "更內卡.md"), + } { + if _, err := os.Stat(p); err != nil { + t.Fatalf("還在看守的資料夾底下的東西被刪了:%s(%v)", p, err) + } + } + if _, err := os.Stat(filepath.Join(root, wikiRelDir)); !os.IsNotExist(err) { + t.Fatalf("外層自己的 .wiki 該刪掉") + } +} + +// ── ⑦ 看得見的卡片產物區:只刪帶 arcrun- 標記的檔,目錄與別人的卡不動 ── + +func TestCleanupMarkedCardsOnly(t *testing.T) { + root := t.TempDir() + cards := filepath.Join(root, filepath.FromSlash(cardsRelDir)) + cuWriteFile(t, filepath.Join(cards, "arcrun-機器卡.md"), "# 機器卡\n") + cuWriteFile(t, filepath.Join(cards, "leo自己的卡.md"), "# 我的\n") + cuWriteFile(t, filepath.Join(root, "system-dev", "wiki", "status.md"), "我的 status\n") + + _, res, err := ApplyCleanup(root, nil) + if err != nil { + t.Fatal(err) + } + if len(res.Failed) != 0 { + t.Fatalf("不該有失敗:%+v", res.Failed) + } + if _, err := os.Stat(filepath.Join(cards, "arcrun-機器卡.md")); !os.IsNotExist(err) { + t.Fatalf("帶標記的卡該刪掉") + } + for _, p := range []string{ + filepath.Join(cards, "leo自己的卡.md"), + filepath.Join(root, "system-dev", "wiki", "status.md"), + cards, + } { + if _, err := os.Stat(p); err != nil { + t.Fatalf("不該動的東西被動了:%s(%v)", p, err) + } + } +} + +// ── ⑧ symlink 不跟隨、不刪(指出去的連結會刪到監看根以外)── + +func TestCleanupNeverFollowsSymlink(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + cuWriteFile(t, filepath.Join(outside, "外面的重要檔.md"), "別碰\n") + makeWiki(t, filepath.Join(root, wikiRelDir), "卡.md") + if err := os.Symlink(outside, filepath.Join(root, "捷徑")); err != nil { + t.Skipf("這台建不了 symlink:%v", err) + } + // 連 .wiki 自己是條捷徑的情況也擋 + link2 := filepath.Join(root, "sub", wikiRelDir) + if err := os.MkdirAll(filepath.Join(root, "sub"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, link2); err != nil { + t.Fatal(err) + } + + plan, res, err := ApplyCleanup(root, nil) + if err != nil { + t.Fatal(err) + } + if len(res.Failed) != 0 { + t.Fatalf("不該有失敗:%+v", res.Failed) + } + for _, rel := range relsOf(plan.Remove) { + if strings.HasPrefix(rel, "捷徑") || strings.HasPrefix(rel, "sub/") { + t.Fatalf("動到 symlink:%s", rel) + } + } + if _, err := os.Stat(filepath.Join(outside, "外面的重要檔.md")); err != nil { + t.Fatalf("監看根以外的檔被刪了:%v", err) + } +} + +// ── ⑨ PlanCleanup 保證不寫檔(「先看清單」的前提就是看清單這件事本身無副作用)── + +func TestPlanCleanupWritesNothing(t *testing.T) { + root := t.TempDir() + cuWriteFile(t, filepath.Join(root, "我的.md"), "x\n") + makeWorkspace(t, root) + makeWiki(t, filepath.Join(root, wikiRelDir), "卡.md") + makeWiki(t, filepath.Join(root, "docs", wikiRelDir), "卡2.md") + + before := cuSnapshotTree(t, root) + plan, err := PlanCleanup(root, nil) + if err != nil { + t.Fatal(err) + } + if len(plan.Remove) == 0 { + t.Fatal("這份擺法應該找得到東西") + } + after := cuSnapshotTree(t, root) + if len(before) != len(after) { + t.Fatalf("PlanCleanup 動了檔案:before=%d after=%d", len(before), len(after)) + } + for k, v := range before { + if after[k] != v { + t.Fatalf("PlanCleanup 改了 %s", k) + } + } + // 每一筆都要說得出依據——沒依據的東西不准進清單。 + for _, it := range plan.Remove { + if strings.TrimSpace(it.Evidence) == "" { + t.Fatalf("這一筆沒有依據:%+v", it) + } + } +} + +// ── ⑩ 帳本認卡:卡名不帶標記、也不是 00-INDEX,靠 manifest.json 認出來 ── + +func TestCleanupAccountsCardsViaManifest(t *testing.T) { + root := t.TempDir() + wiki := filepath.Join(root, wikiRelDir) + cuWriteFile(t, filepath.Join(wiki, "手動卡.md"), "# 手動卡\n") + cuWriteFile(t, filepath.Join(wiki, "manifest.json"), `{"version":1,"docs":[ + {"node":"(根)","path":"a.md","doc_id":"abc","status":"extracted","cards":[".wiki/手動卡.md"]}]}`) + // 故意不放 .gitignore ⇒ 只能靠帳本認人 + plan, err := PlanCleanup(root, nil) + if err != nil { + t.Fatal(err) + } + if got := relsOf(plan.Remove); !equalStrings(got, []string{".wiki"}) { + t.Fatalf("帳本上的卡+帳本自己=全部認得,應該整個目錄刪掉,實際 %v(keep=%v)", got, keepRels(plan.Keep)) + } +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func contains(ss []string, want string) bool { + for _, s := range ss { + if s == want { + return true + } + } + return false +} diff --git a/cmd/arcrun-app/.version-source-files.json b/cmd/arcrun-app/.version-source-files.json index 8c74fae..ab37aef 100644 --- a/cmd/arcrun-app/.version-source-files.json +++ b/cmd/arcrun-app/.version-source-files.json @@ -1,10 +1,10 @@ { "_algo": 4, - "version": "0.18.34", - "fingerprint": "d8c7490b9d32e62e", + "version": "0.18.35", + "fingerprint": "3bd7f3fb0124bda4", "files": { ".gitignore": "4d56952b0fb13bf8f9b6c13a6d4c34a075bac3af447636a1df4335d7576e2f97", - "CHANGELOG.md": "435942d764a283e1637b2ac7b6bc7304b9fc1e79b152f89780d1fa8e62440aa4", + "CHANGELOG.md": "1c740bc8fa983be721c80c95b7941504d3d5e42c6e8d663a59d9c294d72e5877", "DAEMON_LINE": "d5019abbdc8a5f2919e9e3510391891cd7fbdf0765bf16ec83caa779f370116d", "README.md": "9d92cac236b20a0b183eea3e7f5e39ad492f05192c4ea602eb11c3d09967327f", "check-standalone.sh": "65fbce096326791c2f103a51e76d0ad79e9e510e2a7700b49ac2d2d81f73c853", @@ -13,8 +13,10 @@ "cloud_version.go": "eebc2552e09e199928e82607bebf90daef36b5721ec9b8a098304c10dbec46f9", "cloud_version_test.go": "66c004de1f0a3958851f22a22367065f13b3381ac7d936ae54d812c26a0b9fb6", "cmd/arcrun-app/.gitignore": "7502ba597654ebb3463535bdfb51c271abad666a67c7ac8ab36deca1a72615f8", - "cmd/arcrun-app/README.md": "9ea849308b5e578d9a43b73fc3d72637d21428eca9c96a448d31078e8627bcb4", - "cmd/arcrun-app/app.go": "4c65f895c68f82a84d633e6e6d05fbabb86e51632f9ae3d693e39a67ca2b981c", + "cmd/arcrun-app/README.md": "23e53fdf53fcd96b6d8d7a121eb91d3242d11b69d655770473b745c49bb387b9", + "cmd/arcrun-app/app.go": "3d61ca98be11ff8049808331eee07b6001cc24b44d23440c694b267a0b3cd942", + "cmd/arcrun-app/apps.go": "0df6da9d55adbdec500fbb01005a305287d1dbf796b7e463f2b2f222626b4c96", + "cmd/arcrun-app/apps_test.go": "0e2f137f8b49f421e869ab3192ca12c8bb756029d8730487ccc44733aaf7f96c", "cmd/arcrun-app/arcrun-app": "e2bdf54a15d69860df05820274a80d7497b7d62a4e8ba219f72f30b543456b79", "cmd/arcrun-app/assets/store/README.md": "a9028a727947c5038ca938c664b18e4851ae3bf0e6d8838c82aedea43d02ded2", "cmd/arcrun-app/assets/store/Square150x150Logo.png": "27942d943096ba23f036b030726e905f6df6123428cd7d8ec687ce5b11e42db1", @@ -48,9 +50,10 @@ "cmd/arcrun-app/build/windows/wails.exe.manifest": "c63cb75ce48fb1354c95264f6fc43a7686a9b85a82a71efa9722ae4ce6561dc0", "cmd/arcrun-app/changelog-section.sh": "8b39dfdfbf8670fc850102e43b056b98dda52b2a7bd2a49f8d961c171c7abe23", "cmd/arcrun-app/check-cis.sh": "0627a2187160fd1f8c9f618588f6398c4b087a91394e8d387f9a9710f79dd8a5", + "cmd/arcrun-app/check-launcher.mjs": "0c9b4ebd142554b69c1eedf46e5618078b60a050b9f7e5afbf6d3c34d509337d", "cmd/arcrun-app/check-render.sh": "8cd0fb4fe975111f74afb3bc841bc34fe9b160aeddcf67559a504c5187b8ae02", "cmd/arcrun-app/check-tray.sh": "7ff5c5e4d3dba6a356d9b556fc93cefe1c9e93827362ddd553fdb02717701cf5", - "cmd/arcrun-app/connect.go": "18fc927ab55858596c1c6b9e913694fd60cc6dd0c12f4f1640144dcf7f12b1d9", + "cmd/arcrun-app/connect.go": "0da69f6b781c3283851304511b1bd2ebd6f3b9663ae28b3605bab13072d39f9d", "cmd/arcrun-app/daemon-notes.mjs": "452434bf3d744366717219968ee94ba7bb773dbbcd62d69a46937cbb967811b9", "cmd/arcrun-app/daemon-version.py": "786a0212800dd5c5d3a9ff9faf5c5930be1428f033bc20a9de9399a30b18a824", "cmd/arcrun-app/daemon-version.test.mjs": "ee052f19806ba0fa3970442199a57dc76d69829c5b72f9e3879dc50ce0f1abfc", @@ -79,8 +82,8 @@ "cmd/arcrun-app/frontend/src/assets/fonts/OFL.txt": "8656c2a27357b3140047a39b27e3cd928256544ad8a004ef77087f761358208c", "cmd/arcrun-app/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2": "06f3af3fe52542d40ad9bc14ec03e04deaabd09ec369221cc8f536db1c72bf55", "cmd/arcrun-app/frontend/src/assets/images/logo-universal.png": "157a874acd3363634747287c0b01034e9f1eec838a4cf39827dfea789e730494", - "cmd/arcrun-app/frontend/src/main.js": "75bec60d9af627c990b2f2007ffd70e28e33a4a728522099777e028cd670e385", - "cmd/arcrun-app/frontend/src/style.css": "08e890f3f0d7f603249175d5415f64c43370d08bbd30b66c7ccb39a58e399169", + "cmd/arcrun-app/frontend/src/main.js": "9c448240ae49491081cb3dec467801fe7d02893f15be4a074f41542786173564", + "cmd/arcrun-app/frontend/src/style.css": "24b3797c731720b60ce8a2a19582b4a76e8e37a9d2eacce8dbe205ba4008849b", "cmd/arcrun-app/gen-icons.py": "df5b3b33e500fc564badfc8bd2a9ca6a43f7d7e3b31111c41e3b322137c4c96c", "cmd/arcrun-app/go.mod": "8b9e57bcfd44886d1ec06480e033af706ff3fed470786c57074900dd48ecf86f", "cmd/arcrun-app/go.sum": "bc4453654c203e5af251c5a3061616cb0ceeb1b6a78a02ace2ca85524d217256", @@ -95,6 +98,7 @@ "cmd/arcrun-app/supervise.go": "f7eb6c74524b5d8d0abe520b831a98b85d19d18c00d1a585493ababcbd01c81f", "cmd/arcrun-app/tray_darwin.go": "ff3cb22fc2663b08b9f3cd14887ab4411ed338ec8920b58ad0fac029f09edae0", "cmd/arcrun-app/tray_darwin.m": "b4ddf0bb420adf00d580d65c8cfbbe130a78e803dd1e92a1617ddaf6ef172fac", + "cmd/arcrun-app/tray_other.go": "0c3df95b5f7999f6319d0bf5e24fdbf821bf97a79db04077d9e7eaf358299880", "cmd/arcrun-app/tray_windows.go": "adb4f390d3523ac3676f16c27f2b5ea4b9cee850113bc767a45ab9ce503d4963", "cmd/arcrun-app/update_api.go": "1e94052085cea79d406a06772fd171944090a31c30af0456f57b214b065d4d87", "cmd/arcrun-app/upgrade_repair_test.go": "65413d0b07b6c011de2af2783247d0f602a680a199229b52a77e176626c8ef26", diff --git a/cmd/arcrun-app/.version-source.json b/cmd/arcrun-app/.version-source.json index 2b92a74..4e69e4f 100644 --- a/cmd/arcrun-app/.version-source.json +++ b/cmd/arcrun-app/.version-source.json @@ -4,5 +4,6 @@ "0.18.31": "d6b2b6083258b853", "0.18.32": "4753db807169fe3d", "0.18.33": "7ba0f6dd2d080925", - "0.18.34": "d8c7490b9d32e62e" + "0.18.34": "d8c7490b9d32e62e", + "0.18.35": "3bd7f3fb0124bda4" } diff --git a/cmd/arcrun-app/README.md b/cmd/arcrun-app/README.md index 397b08b..23c1e43 100644 --- a/cmd/arcrun-app/README.md +++ b/cmd/arcrun-app/README.md @@ -1,19 +1,84 @@ -# README +# Arcrun 桌面小幫手(Wails)— 地端 build 說明 -## About +> 這份取代了原本 `wails init` 留下的英文樣板 README(那份只寫「用 `wails build`」, +> 而這裡真正的打包線是旁邊那四支腳本,不是裸的 `wails build`)。 +> +> 🔴 **為什麼會有這份**(leo 2026-08-24,`inkstone/arcrun-rag#137`): +> 「daemon 部分要傳給地端做,**或是你做好給它 build**」——採後者。 +> 雲端只交**編得起來的 code**,安裝檔(DMG/exe/msix)由地端打, +> 因為那需要 macOS/Windows 環境與簽章,雲端沒有。 -This is the official Wails Vanilla template. +--- -You can configure the project by editing `wails.json`. More information about the project settings can be found -here: https://wails.io/docs/reference/project-config +## 一、一次性前置(每台機器裝一次) -## Live Development +| 需要 | 怎麼裝 | 用來做什麼 | +|---|---|---| +| Go **1.25+** | `brew install go`(`go.mod` 寫 `go 1.25.0`;較舊的 Go 會自己抓 toolchain) | 編 App 與 collector | +| Node 18+ | `brew install node` | 編前端(Vite) | +| Wails CLI v2 | `go install github.com/wailsapp/wails/v2/cmd/wails@v2.13.0` | 打包 | +| mingw-w64 | `brew install mingw-w64` | **只有要打 Windows 版才需要**(WebView2 要 CGo 交叉編譯) | +| cmake、icu4c | `brew install cmake icu4c` + `bash build-msix.sh --setup` | **只有要送 Microsoft Store 才需要** | -To run in live development mode, run `wails dev` in the project directory. This will run a Vite development -server that will provide very fast hot reload of your frontend changes. If you want to develop in a browser -and have access to your Go methods, there is also a dev server that runs on http://localhost:34115. Connect -to this in your browser, and you can call your Go code from devtools. +> `wails` 裝完記得 `export PATH="$PATH:$(go env GOPATH)/bin"`(四支 build 腳本自己也會補這行)。 -## Building +## 二、打包:跑既有的腳本,不要自己下 `wails build` -To build a redistributable, production mode package, use `wails build`. +```sh +cd collector/cmd/arcrun-app + +./build-mac.sh # → build/bin/Arcrun.app +./build-dmg.sh # → dist/Arcrun-<版本>.dmg (自己會先跑 build-mac.sh) +./build-win.sh # → dist/Arcrun-<版本>.exe (在 Mac 上交叉編譯,需 mingw-w64) +./build-msix.sh # → dist/Arcrun-<版本>.msix (送 Store 用;不必自購憑證) +``` + +🔴 **版本號不要手打。** `daemon-version.py --stamp` 會從 `collector/CHANGELOG.md` +最上面那個「下一版(未發佈)」段落戳出版號並補上日期——四支腳本都已經呼叫它。 +本次(`#137`)的 changelog 段落已經寫好,所以**這一版打出來會是 `0.18.35`** +(`./daemon-version.py` 唯讀查詢可以先確認)。 + +🔴 打包前若改過任何 `collector/` 底下的檔,指紋會變 ⇒ 同一個版號不准對應兩份原始碼, +腳本會擋下來並告訴你怎麼辦。那是刻意的閘,不要繞過它。 + +## 三、交貨前自己先驗(這三支都在本目錄,跑得很快) + +```sh +go test ./... # 後端(含本次新增的 App 啟動器 12 條) +bash check-cis.sh # CIS 合規(色票/lockup/選中態) +npm --prefix frontend run build && node check-launcher.mjs # 畫面與互動(需 playwright) +``` + +`check-launcher.mjs` 會真的把 `frontend/dist` 渲染出來、真的點下去, +截圖丟在 `/tmp/arcrun-launcher-shots/`。沒裝 playwright 它會自己跳過 +(`npm i -D playwright && npx playwright install chromium`)。 + +macOS 上另外還有 `check-render.sh`(量 lockup 像素與深色模式),需要本機的 Chrome。 + +## 四、只有在真機上才驗得到的(雲端做不到,地端請補) + +雲端這台沒有 GUI、也沒有 WebView,所以下面這些**沒有被驗過**, +打包完請在真機上走一次: + +1. `wails dev`(或直接開打好的 `Arcrun.app`)能不能起來、視窗尺寸對不對。 +2. 系統匣:Mac 是原生 `NSStatusItem`(`tray_darwin.m`)、Windows 是 `energye/systray`; + 點 icon 開窗、右鍵只有「結束 Arcrun」。 +3. **App 啟動器**(本次新增):打開就落在「App 界面」,九宮格列出的是你連的那個 + 知識庫實際裝了的 App;點一個進去、按一下它的動作,看結果回不回得來。 +4. Windows:WebView2 有沒有裝、SmartScreen 會不會攔、Defender 會不會誤判。 + +## 五、平台檔案對照(改東西前先看這張) + +| 檔 | build tag | 說明 | +|---|---|---| +| `tray_darwin.go` / `tray_darwin.m` | `darwin` | macOS 原生 NSStatusItem | +| `tray_windows.go` | `windows` | Windows systray | +| `tray_other.go` | `!darwin && !windows` | **no-op**。讓 `go build`/`go test` 在 Linux/CI 上跑得起來,對出貨零影響(Linux 不是出貨平台) | +| `dock_darwin.go` / `dock_other.go` | 同上 | Dock icon 隱藏 | + +## 六、開發時的即時預覽 + +```sh +wails dev +``` +Vite 熱重載;`http://localhost:34115` 可以用瀏覽器連進去,Go 方法一樣叫得到。 diff --git a/cmd/arcrun-app/app.go b/cmd/arcrun-app/app.go index 1d141f0..471d36c 100644 --- a/cmd/arcrun-app/app.go +++ b/cmd/arcrun-app/app.go @@ -68,6 +68,15 @@ type accountCfg struct { RetiringFolders []string `json:"retiring_folders,omitempty"` Extractor string `json:"extractor,omitempty"` GeminiAPIKey string `json:"gemini_api_key,omitempty"` + // PortalSession/PortalSessionExp=這台電腦對這個知識庫的 portal 登入憑證 + // (arcrun-rag#137 App 啟動器用;App 詳情與動作只有 portal session 那條路有, + // 見 apps.go 檔頭)。**這是 session token 不是密碼**——密碼仍然零落地。 + // + // 🔴 這兩欄是 App 端獨有的(collector 的 AccountConfig 沒有):collector 讀 config + // 時會忽略不認得的欄位,所以加在這裡是安全的;反過來(collector 有而這裡沒有) + // 才是上面那條註解講的、會靜默掉欄位的方向。 + PortalSession string `json:"portal_session,omitempty"` + PortalSessionExp int64 `json:"portal_session_exp,omitempty"` } type directConfig struct { @@ -719,7 +728,18 @@ func (a *App) AddFolder(accIdx int, path string) error { // vs 我不要這份資料了),而**猜錯任何一邊都是不可逆的**——猜「保留」則產品承諾的 // 「資料所有權完全屬於使用者」是假的;猜「收回」則整理好的知識被誤刪。 // ⇒ 在動作的當下把兩個後果講清楚、讓他自己挑(見前端 confirmRemove 的文案)。 -func (a *App) RemoveFolder(accIdx int, path string, takedown bool) error { +// +// cleanupLocal=第三個獨立選擇(arcrun-rag#138,leo 2026-08-24:「碎型會在每個資料夾 +// 安裝隱藏資料夾,人工刪除不容易,所以當它斷連,應該要可以幫它把 Arcrun RAG 建立的 +// 資料夾刪掉」):連同硬碟上那些隱藏資料夾一起收掉。 +// +// 與 takedown **正交,不合成一個開關**:takedown 管**雲端**的知識(搜尋還找不找得到), +// cleanupLocal 管**他硬碟上**的產物(資料夾乾不乾淨)。兩個後果不一樣,合起來就是替他決定 +// ——這正是 #46 那次修掉的病,別再犯一次。 +// +// 🔴 順序不可顛倒:**先把資料夾從 WatchFolders 拿掉並重啟看守,再刪本地產物。** +// 反過來的話 collector 還看著那個根,剛刪掉的 `.wiki/` 下一輪就長回來。 +func (a *App) RemoveFolder(accIdx int, path string, takedown, cleanupLocal bool) error { cfg, err := loadCfg() if err != nil { return err @@ -753,6 +773,17 @@ func (a *App) RemoveFolder(accIdx int, path string, takedown bool) error { return err } restartWatch() + if cleanupLocal && found { + // 看守已經停掉了(restartWatch 讀的是剛存下去的設定),現在刪才不會被重建。 + // 刪不掉的那幾筆不讓整個移除失敗——資料夾已經從清單拿掉了,那是使用者要的主要結果; + // 清理是附帶的善後,回一個講得出「哪幾筆沒刪掉、為什麼」的錯誤就好。 + if _, res, cerr := collector.ApplyCleanup(path, cfg.otherWatchedRoots(accIdx, path)); cerr != nil { + return fmt.Errorf("資料夾已經移除,但清理 Arcrun RAG 建立的檔案時出錯:%w", cerr) + } else if len(res.Failed) > 0 { + return fmt.Errorf("資料夾已經移除,但有 %d 個項目刪不掉(第一個:%s——%s)", + len(res.Failed), res.Failed[0].Rel, res.Failed[0].Error) + } + } if takedown { // 不必等下一輪輪詢——使用者剛按下按鈕,他期待「現在就開始」。 // 沿用既有的 sync-now 訊號檔,不新發明一套 IPC。 @@ -761,6 +792,43 @@ func (a *App) RemoveFolder(accIdx int, path string, takedown bool) error { return nil } +// PlanFolderCleanup 回答「按下『連同這個資料夾裡的檔案一起清掉』會刪到哪些東西」。 +// +// 🔴 #138 的驗收條件之一:「使用者要能在動手前看到將要刪掉哪些東西」——不是按下去就無聲刪光。 +// 這支**只讀不寫**(collector.PlanCleanup 保證),前端在確認對話框裡先叫它、把清單攤出來。 +// 真的動手時 ApplyCleanup 會**重算一次**,所以這裡拿到的清單過期也不會刪錯東西。 +func (a *App) PlanFolderCleanup(accIdx int, path string) (*collector.CleanupPlan, error) { + cfg, err := loadCfg() + if err != nil { + return nil, err + } + if accIdx < 0 || accIdx >= len(cfg.Accounts) { + return nil, fmt.Errorf("找不到這個知識庫帳號") + } + return collector.PlanCleanup(path, cfg.otherWatchedRoots(accIdx, path)) +} + +// otherWatchedRoots=除了 path 以外、**所有帳號**還在看守(或還在收回中)的資料夾。 +// +// 🔴 為什麼要跨帳號收集:巢狀擺法是真的(2026-08-24 現場,`pms` 與 `pms/pms_v1_legacy` +// 同時在同一個帳號的看守清單裡)。移除外層時把內層的工作區刪掉=把一個還在跑的同步弄壞, +// 而且不同帳號可以看守同一棵樹底下的不同層——判準是「這條路徑還有沒有人在用」, +// 跟它掛在哪個帳號無關。 +func (c *directConfig) otherWatchedRoots(accIdx int, path string) []string { + var out []string + for i := range c.Accounts { + for _, f := range c.Accounts[i].WatchFolders { + if i == accIdx && f == path { + continue + } + out = append(out, f) + } + // 還在收回中的資料夾同樣還沒結束——它的帳本正在被逐筆撤除,別去動它的檔。 + out = append(out, c.Accounts[i].RetiringFolders...) + } + return out +} + // pruneFinishedRetirements 把「collector 已回報收乾淨」的資料夾從設定裡清掉。 // // 為什麼由 App 清而不是 collector 自己清:config.json 的寫入者只有 App 一個, diff --git a/cmd/arcrun-app/apps.go b/cmd/arcrun-app/apps.go new file mode 100644 index 0000000..521ba8e --- /dev/null +++ b/cmd/arcrun-app/apps.go @@ -0,0 +1,495 @@ +package main + +// apps.go — 桌面小幫手的 App 啟動器後端(inkstone/arcrun-rag#137) +// +// ───────────────────────────────────────────────────────────────────────────── +// 這個檔案在解什麼 +// ───────────────────────────────────────────────────────────────────────────── +// leo 2026-08-24:「所有的 App 需要有一個類似 Android/iOS 的九宮格啟動界面, +// 每個 App 有一個 icon,**這會運行在 portal 及 daemon**。」 +// +// 桌面這半沒有自己的 KBDB,也**不准**在本機另存一份 App 清單 +// (上游 `inkstone/Arcrun#82` 已定「安裝態只有一份真相源」=實例的 `{tenant}:app:{id}`)。 +// ⇒ 本檔做的事只有一件:**去使用者連的那個實例問**,把答案原樣交給畫面。 +// 沒有任何預設清單、沒有快取到磁碟、沒有第二份 schema。 +// +// ───────────────────────────────────────────────────────────────────────────── +// 為什麼是「兩條路」而不是一條(這是本票的規格判斷,理由寫在這裡不寫在票上) +// ───────────────────────────────────────────────────────────────────────────── +// 上游同一份安裝態有兩個對外面: +// +// ① `/portal/data/apps*` 認 **portal session**(Bearer)——Portal 那半走這條, +// 清單/詳情(含畫面 HTML)/動作三支齊全。 +// ② `/apps` 認 **X-Arcrun-API-Key**(=實例 namespace)——CLI/操盤 AI 走這條, +// **只有清單**,沒有詳情、沒有動作。 +// +// 小幫手手上本來就有 ②的鑰匙(`config.json` 的 `api_key`,連線時實例自己下發的), +// 但**沒有**①的 session(密碼刻意零落地,見 connect.go)。 +// +// 所以: +// - **清單一律先走①、拿不到就退回②**。兩支回的都是 `summarizeApp` 的同一個形狀 +// (id/name/icon/has_ui/version),所以九宮格**在完全沒登入的情況下也是真資料**。 +// - **詳情與動作只有①有**,所以那兩件需要一次 portal 登入。連線精靈當下順手換一張 +// session(`Connect` 已經有密碼在手,見 connect.go 的呼叫點),使用者通常不會再被問。 +// session 過期(實例預設 7 天)才會在 App 頁上出現一次登入框。 +// +// 🔴 密碼仍然零落地:存的是 session token,不是密碼(與 connect.go 同一條紅線)。 +// +// ───────────────────────────────────────────────────────────────────────────── +// 不做輪詢 +// ───────────────────────────────────────────────────────────────────────────── +// 畫面每秒打一次 `GetState()`,但**本檔的任何函式都不掛在那條線上**—— +// App 清單只在「打開啟動器」與「按重新整理」時才去問實例一次。 +// 把網路請求塞進每秒的 tick 等於自己造一個輪詢器,那是明令禁止的形狀。 +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// appHTTPTimeout:App 動作背後是一整條工作流(可能打 LLM),比單純讀清單久得多。 +// 清單/詳情用短的,動作用長的——不讓「讀清單」被一條慢工作流的預算拖著。 +const ( + appReadTimeout = 20 * time.Second + appActionTimeout = 120 * time.Second +) + +// ── 前端要的形狀 ─────────────────────────────────────────────────────────── + +// UIApp=九宮格上的一格。逐欄對應上游 `summarizeApp`,不多不少—— +// 多一欄就是在桌面端發明一個實例不認得的概念。 +type UIApp struct { + ID string `json:"id"` + Name string `json:"name"` + Icon string `json:"icon"` + HasUI bool `json:"hasUi"` + Version string `json:"version"` +} + +// UIAppList=一個知識庫的啟動器內容。 +// +// 🔴 Error 與「空清單」是兩件事,不准混成同一句話(arcrun-rag#10「寧可明顯失敗」): +// - `Error != ""` ⇒ 問不到(連不上/實例太舊沒有這支端點)——畫面要說「問不到」 +// - `Error == "" && len(Apps) == 0` ⇒ 真的一個都沒裝——畫面要說「還沒有 App」 +// +// 兩者長得像,但使用者該做的事完全相反。 +type UIAppList struct { + AccIdx int `json:"accIdx"` + Account string `json:"account"` // 知識庫顯示名(畫面上的切換器用) + Host string `json:"host"` + Apps []UIApp `json:"apps"` + Error string `json:"error"` + // Source=這份清單是用哪把鑰匙問到的("session"/"apikey")。給診斷用, + // 畫面不依它分支——兩條路的內容是同一份安裝態。 + Source string `json:"source"` +} + +// UIAppWorkflow=App 的一條工作流(沒有自帶畫面的 App 就靠這個列表當預設畫面, +// 與 Portal 的 renderAppView 同一套呈現,不另立第二種)。 +type UIAppWorkflow struct { + Name string `json:"name"` + Description string `json:"description"` +} + +// UIAppDetail=點進一個 App 之後要的全部東西。 +type UIAppDetail struct { + ID string `json:"id"` + Name string `json:"name"` + Icon string `json:"icon"` + Version string `json:"version"` + HasUI bool `json:"hasUi"` + UIHtml string `json:"uiHtml"` + Workflows []UIAppWorkflow `json:"workflows"` + Actions []string `json:"actions"` + // NeedsLogin=這台機器沒有(或已過期)這個知識庫的 portal session。 + // 不是錯誤,是「還差一步」——畫面要長出登入框,不是紅字。 + NeedsLogin bool `json:"needsLogin"` + Email string `json:"email"` // 登入框預填(帳號本來就存在 config 裡) + Error string `json:"error"` +} + +// ── 內部:HTTP 小工具 ───────────────────────────────────────────────────── + +// apiBaseOf 回傳這個帳號的 API base(cypher origin)。 +// 連線精靈存進來的 CypherURL 已經是 API 位址(normalizePortalURL 換算過), +// 這裡只做去尾斜線,**不重新發明第二套換算**。 +func apiBaseOf(a accountCfg) string { + return strings.TrimRight(strings.TrimSpace(a.CypherURL), "/") +} + +// accountAt 取第 idx 個帳號;越界回錯(前端傳來的索引可能落後於剛改過的設定)。 +func accountAt(cfg *directConfig, idx int) (accountCfg, error) { + if cfg == nil || idx < 0 || idx >= len(cfg.Accounts) { + return accountCfg{}, errors.New("找不到這個知識庫") + } + return cfg.Accounts[idx], nil +} + +// appDo 送一個請求並讀回 body。回傳 (status, body, err);err 只代表「連不上」, +// HTTP 4xx/5xx 一律當成「連得上但被拒絕」由呼叫端判讀—— +// 把兩者混成一個 error 就分不出「網路斷了」與「密碼過期了」。 +func appDo(req *http.Request, timeout time.Duration) (int, []byte, error) { + res, err := (&http.Client{Timeout: timeout}).Do(req) + if err != nil { + return 0, nil, errors.New("連不上這個知識庫——請確認網路正常") + } + defer res.Body.Close() + b, _ := io.ReadAll(io.LimitReader(res.Body, 4<<20)) // App 自帶畫面可能不小,但給上限 + return res.StatusCode, b, nil +} + +// appErrMessage 從實例回的 JSON 撈 error 欄位;撈不到就用狀態碼講人話。 +func appErrMessage(status int, body []byte) string { + var e struct { + Error string `json:"error"` + } + if json.Unmarshal(body, &e) == nil && strings.TrimSpace(e.Error) != "" { + return e.Error + } + switch status { + case http.StatusNotFound: + return "這個知識庫還沒有 App 功能(實例版本較舊,更新後就會出現)" + case http.StatusUnauthorized, http.StatusForbidden: + return "這個知識庫的登入已過期" + } + return fmt.Sprintf("知識庫回了 HTTP %d", status) +} + +// ── portal session(詳情與動作唯一的鑰匙)──────────────────────────────── + +// sessionValid:留 60 秒安全邊際,免得剛好在請求路上過期。 +func sessionValid(a accountCfg) bool { + return strings.TrimSpace(a.PortalSession) != "" && + (a.PortalSessionExp == 0 || time.Now().Unix() < a.PortalSessionExp-60) +} + +// portalLogin 用帳密換一張 portal session token。 +// +// 🔴 這支是**唯一**碰密碼的地方,而且密碼只活在參數裡:換完就丟,不寫檔、不 log。 +func portalLogin(base, email, password string) (token string, expiresIn int64, err error) { + body, _ := json.Marshal(map[string]string{ + "email": strings.ToLower(strings.TrimSpace(email)), + "password": password, + }) + req, err := http.NewRequest(http.MethodPost, base+"/portal/login", bytes.NewReader(body)) + if err != nil { + return "", 0, err + } + req.Header.Set("Content-Type", "application/json") + status, raw, err := appDo(req, appReadTimeout) + if err != nil { + return "", 0, err + } + var out struct { + Success bool `json:"success"` + SessionToken string `json:"session_token"` + SessionExpires int64 `json:"session_expires_in"` + Error string `json:"error"` + } + _ = json.Unmarshal(raw, &out) + if status != http.StatusOK || !out.Success || out.SessionToken == "" { + if strings.TrimSpace(out.Error) != "" { + return "", 0, errors.New(out.Error) + } + return "", 0, errors.New(appErrMessage(status, raw)) + } + return out.SessionToken, out.SessionExpires, nil +} + +// storeSession 把換到的 session 寫回設定(只存 token 與到期時間,不存密碼)。 +func storeSession(idx int, token string, expiresIn int64) error { + cfg, err := loadCfg() + if err != nil { + return err + } + if idx < 0 || idx >= len(cfg.Accounts) { + return errors.New("找不到這個知識庫") + } + cfg.Accounts[idx].PortalSession = token + if expiresIn > 0 { + cfg.Accounts[idx].PortalSessionExp = time.Now().Unix() + expiresIn + } else { + cfg.Accounts[idx].PortalSessionExp = 0 + } + return saveCfg(cfg) +} + +// clearSession 在實例說「這張 session 不算數」時把它丟掉。 +// 留著一張已知無效的 token 只會讓下一次再撞一次 401,然後畫面又閃一下錯誤。 +func clearSession(idx int) { + cfg, err := loadCfg() + if err != nil || idx < 0 || idx >= len(cfg.Accounts) { + return + } + if cfg.Accounts[idx].PortalSession == "" && cfg.Accounts[idx].PortalSessionExp == 0 { + return + } + cfg.Accounts[idx].PortalSession = "" + cfg.Accounts[idx].PortalSessionExp = 0 + if err := saveCfg(cfg); err != nil { + appLog("清掉過期的知識庫登入失敗:%v", err) + } +} + +// tryStoreSessionFromLogin 在連線精靈成功之後順手換一張 session(connect.go 呼叫)。 +// **失敗不算連線失敗**——沒有 session 只是「App 詳情要再登入一次」, +// 而資料夾同步這條主線根本用不到它。 +func tryStoreSessionFromLogin(cfg *directConfig, idx int, email, password string) { + if idx < 0 || idx >= len(cfg.Accounts) { + return + } + token, exp, err := portalLogin(apiBaseOf(cfg.Accounts[idx]), email, password) + if err != nil { + appLog("連線成功但沒換到知識庫的 App 登入(不影響同步):%v", err) + return + } + cfg.Accounts[idx].PortalSession = token + if exp > 0 { + cfg.Accounts[idx].PortalSessionExp = time.Now().Unix() + exp + } +} + +// ── 前端呼叫的四支 ───────────────────────────────────────────────────────── + +// ListApps 回一個知識庫上「實際裝了的」App。 +// +// 兩條路的順序與理由見檔頭。**沒有任何寫死的清單**——問不到就誠實說問不到。 +func (a *App) ListApps(accIdx int) UIAppList { + out := UIAppList{AccIdx: accIdx} + cfg, _ := loadCfg() + acc, err := accountAt(cfg, accIdx) + if err != nil { + out.Error = err.Error() + return out + } + out.Account = accountName(acc) + out.Host = shortHost(acc.CypherURL) + base := apiBaseOf(acc) + if base == "" { + out.Error = "這個知識庫沒有網址,請重新連線一次" + return out + } + + // ① portal session(與 Portal 那半同一支端點、同一份權限) + if sessionValid(acc) { + req, _ := http.NewRequest(http.MethodGet, base+"/portal/data/apps", nil) + req.Header.Set("Authorization", "Bearer "+acc.PortalSession) + status, raw, derr := appDo(req, appReadTimeout) + switch { + case derr != nil: + out.Error = derr.Error() + return out + case status == http.StatusOK: + out.Apps = parseAppList(raw) + out.Source = "session" + return out + case status == http.StatusUnauthorized || status == http.StatusForbidden: + clearSession(accIdx) // 過期了 ⇒ 掉到 ② 去,清單照樣看得到 + default: + // 其他錯(例如 5xx)**不要**掉到 ②——那會把「實例出問題」偽裝成正常。 + out.Error = appErrMessage(status, raw) + return out + } + } + + // ② 實例 API key(小幫手本來就有的那把;只有清單,沒有詳情/動作) + if strings.TrimSpace(acc.APIKey) == "" { + out.Error = "這台電腦還沒有這個知識庫的鑰匙,請重新連線一次" + return out + } + req, _ := http.NewRequest(http.MethodGet, base+"/apps", nil) + req.Header.Set("X-Arcrun-API-Key", acc.APIKey) + status, raw, derr := appDo(req, appReadTimeout) + if derr != nil { + out.Error = derr.Error() + return out + } + if status != http.StatusOK { + out.Error = appErrMessage(status, raw) + return out + } + out.Apps = parseAppList(raw) + out.Source = "apikey" + return out +} + +// parseAppList 讀 `{apps:[…]}`。兩支端點回的都是 summarizeApp 的形狀,所以只有一份解析。 +// 純函式(單測用)。 +func parseAppList(raw []byte) []UIApp { + var body struct { + Apps []struct { + ID string `json:"id"` + Name string `json:"name"` + Icon string `json:"icon"` + HasUI bool `json:"has_ui"` + Version string `json:"version"` + } `json:"apps"` + } + if json.Unmarshal(raw, &body) != nil { + return nil + } + apps := make([]UIApp, 0, len(body.Apps)) + for _, x := range body.Apps { + name := x.Name + if strings.TrimSpace(name) == "" { + name = x.ID + } + apps = append(apps, UIApp{ID: x.ID, Name: name, Icon: x.Icon, HasUI: x.HasUI, Version: x.Version}) + } + return apps +} + +// GetApp 取一個 App 的詳情(自帶畫面/工作流清單/動作白名單)。 +// +// 🔴 只有 portal session 這一條路——上游的 API key 面**沒有**詳情端點 +// +// (`/apps` 只有列表;見 `cypher-executor/src/routes/apps.ts`)。 +// 所以沒有 session 時回 NeedsLogin,讓畫面長一次登入框,而不是回一句錯誤。 +func (a *App) GetApp(accIdx int, id string) UIAppDetail { + out := UIAppDetail{ID: id} + cfg, _ := loadCfg() + acc, err := accountAt(cfg, accIdx) + if err != nil { + out.Error = err.Error() + return out + } + out.Email = acc.Email + if !sessionValid(acc) { + out.NeedsLogin = true + return out + } + base := apiBaseOf(acc) + req, _ := http.NewRequest(http.MethodGet, base+"/portal/data/apps/"+urlPathEscape(id), nil) + req.Header.Set("Authorization", "Bearer "+acc.PortalSession) + status, raw, derr := appDo(req, appReadTimeout) + if derr != nil { + out.Error = derr.Error() + return out + } + if status == http.StatusUnauthorized || status == http.StatusForbidden { + clearSession(accIdx) + out.NeedsLogin = true + return out + } + if status != http.StatusOK { + out.Error = appErrMessage(status, raw) + return out + } + var d struct { + ID string `json:"id"` + Name string `json:"name"` + Icon string `json:"icon"` + Version string `json:"version"` + HasUI bool `json:"has_ui"` + UIHtml string `json:"ui_html"` + Actions []string `json:"actions"` + Workflows []struct { + Name string `json:"name"` + Description string `json:"description"` + } `json:"workflows"` + } + if json.Unmarshal(raw, &d) != nil { + out.Error = "這個知識庫回的內容看不懂(版本可能不相容)" + return out + } + out.ID, out.Name, out.Icon, out.Version = d.ID, d.Name, d.Icon, d.Version + out.HasUI, out.UIHtml, out.Actions = d.HasUI, d.UIHtml, d.Actions + for _, w := range d.Workflows { + out.Workflows = append(out.Workflows, UIAppWorkflow{Name: w.Name, Description: w.Description}) + } + if strings.TrimSpace(out.Name) == "" { + out.Name = out.ID + } + return out +} + +// RunAppAction 觸發 App 的一個白名單動作,回傳實例給的結果 JSON(原樣字串)。 +// +// 🔴 白名單由**實例**裁決,不在這裡複製一份判斷(K6:前端/桌面端都不是裁決點)。 +// +// 這裡不認得任何 action 名稱,只負責把使用者按的那顆按鈕送出去。 +// +// payloadJSON 是前端組好的 JSON 物件字串;空字串=沒有 payload。 +func (a *App) RunAppAction(accIdx int, id, action, payloadJSON string) (string, error) { + cfg, _ := loadCfg() + acc, err := accountAt(cfg, accIdx) + if err != nil { + return "", err + } + if strings.TrimSpace(action) == "" { + return "", errors.New("沒有指定要做什麼") + } + if !sessionValid(acc) { + return "", errors.New("這個知識庫的登入已過期,請在畫面上重新登入一次") + } + var payload any = map[string]any{} + if s := strings.TrimSpace(payloadJSON); s != "" { + if err := json.Unmarshal([]byte(s), &payload); err != nil { + return "", errors.New("送出的內容格式不對") + } + } + body, _ := json.Marshal(map[string]any{"action": action, "payload": payload}) + req, err := http.NewRequest(http.MethodPost, + apiBaseOf(acc)+"/portal/data/apps/"+urlPathEscape(id)+"/action", bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+acc.PortalSession) + status, raw, derr := appDo(req, appActionTimeout) + if derr != nil { + return "", derr + } + if status == http.StatusUnauthorized || status == http.StatusForbidden { + // 403 也可能是「這個動作不在白名單」——那句話由實例自己講,這裡照抄。 + if status == http.StatusUnauthorized { + clearSession(accIdx) + } + return "", errors.New(appErrMessage(status, raw)) + } + if status != http.StatusOK { + return "", errors.New(appErrMessage(status, raw)) + } + return string(raw), nil +} + +// PortalLogin 讓使用者在 App 頁上補一次登入(session 過期時)。 +// email 用 config 裡存的那個(就是他連線時用的帳號),只問密碼。 +func (a *App) PortalLogin(accIdx int, password string) error { + cfg, _ := loadCfg() + acc, err := accountAt(cfg, accIdx) + if err != nil { + return err + } + if strings.TrimSpace(acc.Email) == "" { + return errors.New("這個知識庫沒有記錄帳號,請用「新增知識庫帳號」重新連一次") + } + token, exp, err := portalLogin(apiBaseOf(acc), acc.Email, password) + if err != nil { + return err + } + return storeSession(accIdx, token, exp) +} + +// urlPathEscape:App id 的字元集上游已限死 `^[a-z][a-z0-9_-]{0,63}$`, +// 但我們是**收方**不是**發方**——照樣 escape,不假設對面永遠守規矩。 +func urlPathEscape(s string) string { + var b strings.Builder + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', + r == '-', r == '_', r == '.', r == '~': + b.WriteRune(r) + default: + for _, c := range []byte(string(r)) { + fmt.Fprintf(&b, "%%%02X", c) + } + } + } + return b.String() +} diff --git a/cmd/arcrun-app/apps_test.go b/cmd/arcrun-app/apps_test.go new file mode 100644 index 0000000..41a0e92 --- /dev/null +++ b/cmd/arcrun-app/apps_test.go @@ -0,0 +1,384 @@ +package main + +// apps_test.go — App 啟動器後端的驗收(inkstone/arcrun-rag#137) +// +// 🔴 這裡的假實例**不是憑印象寫的**:每一支端點的路徑、認證標頭、回應欄位名, +// +// 都是照 `inkstone/Arcrun` 分支 `feat/app-system-v0` 的原始碼抄下來的 +// (`cypher-executor/src/routes/apps.ts`、`routes/portal-data.ts`、 +// `lib/app-system.ts` 的 summarizeApp/detailApp、`routes/portal.ts` 的 /portal/login)。 +// 抄錯了測試就會綠得很假——所以下面每一段都標了它對應上游哪一支。 +// +// 這組測試回答的是驗收條件 1 與 2 裡「機器答得出來」的那幾格: +// - 清單真的來自實例(兩條路都試過),桌面端沒有任何寫死清單 +// - 「問不到」與「一個都沒裝」分得出來 +// - session 過期會被清掉、且清單仍然看得到(退回 API key) +// - 動作真的打到 /portal/data/apps/:id/action,白名單由實例裁決 +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// writeCfgWithAccount 造一份最小可用的 config.json(一個知識庫)。 +func writeCfgWithAccount(t *testing.T, base string, acc accountCfg) { + t.Helper() + acc.CypherURL = base + if acc.Namespace == "" { + acc.Namespace = "ns-test" + } + cfg := map[string]any{ + "manifest": filepath.Join(appDir(), "manifest.json"), + "accounts": []accountCfg{acc}, + } + b, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(appDir(), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configPath(), b, 0o600); err != nil { + t.Fatal(err) + } +} + +func tempHome(t *testing.T) { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) +} + +// fakeInstance 是「一台裝了 App 系統的 Arcrun 實例」的最小替身。 +type fakeInstance struct { + // sessionToken=目前有效的 session;空字串=任何 Bearer 都算過期。 + sessionToken string + password string + apiKey string + // 收到的動作(給測試斷言用) + gotAction string + gotPayload map[string]any + // 讓測試模擬「實例壞掉」 + listStatus int +} + +func (f *fakeInstance) server(t *testing.T) *httptest.Server { + t.Helper() + // 上游 summarizeApp 的形狀(lib/app-system.ts) + summary := []map[string]any{ + {"id": "note", "name": "筆記", "icon": "🗒️", "has_ui": true, "version": "0.1.0"}, + {"id": "weekly", "name": "週報", "icon": "📊", "has_ui": false, "version": "0.2.0"}, + } + mux := http.NewServeMux() + + // POST /portal/login(routes/portal.ts) + mux.HandleFunc("/portal/login", func(w http.ResponseWriter, r *http.Request) { + var in struct{ Email, Password string } + _ = json.NewDecoder(r.Body).Decode(&in) + if in.Password != f.password { + w.WriteHeader(401) + _ = json.NewEncoder(w).Encode(map[string]any{"error": "email 或密碼錯誤"}) + return + } + f.sessionToken = "sess-" + in.Email + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, "session_token": f.sessionToken, "session_expires_in": 604800, + }) + }) + + // GET /apps(routes/apps.ts,X-Arcrun-API-Key) + mux.HandleFunc("/apps", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Arcrun-API-Key") != f.apiKey { + w.WriteHeader(401) + _ = json.NewEncoder(w).Encode(map[string]any{"error": "缺少 X-Arcrun-API-Key header"}) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"apps": summary, "count": len(summary)}) + }) + + // /portal/data/apps*(routes/portal-data.ts,Bearer portal session) + mux.HandleFunc("/portal/data/apps", func(w http.ResponseWriter, r *http.Request) { + if !f.authed(r) { + w.WriteHeader(401) + _ = json.NewEncoder(w).Encode(map[string]any{"error": "session 無效或已過期"}) + return + } + if f.listStatus != 0 { + w.WriteHeader(f.listStatus) + _ = json.NewEncoder(w).Encode(map[string]any{"error": "實例內部錯誤"}) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"apps": summary, "count": len(summary)}) + }) + mux.HandleFunc("/portal/data/apps/", func(w http.ResponseWriter, r *http.Request) { + if !f.authed(r) { + w.WriteHeader(401) + _ = json.NewEncoder(w).Encode(map[string]any{"error": "session 無效或已過期"}) + return + } + rest := strings.TrimPrefix(r.URL.Path, "/portal/data/apps/") + if strings.HasSuffix(rest, "/action") { + id := strings.TrimSuffix(rest, "/action") + var in struct { + Action string `json:"action"` + Payload map[string]any `json:"payload"` + } + _ = json.NewDecoder(r.Body).Decode(&in) + // 上游 runAppAction:白名單不中 → 403(K6,裁決點在實例不在呼叫端) + if id != "note" || in.Action != "notes-create" { + w.WriteHeader(403) + _ = json.NewEncoder(w).Encode(map[string]any{"error": "這個動作不在這個 App 的白名單內"}) + return + } + f.gotAction, f.gotPayload = in.Action, in.Payload + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{"id": "blk_1"}}) + return + } + if rest != "note" { + w.WriteHeader(404) + _ = json.NewEncoder(w).Encode(map[string]any{"error": "找不到這個 App"}) + return + } + // 上游 detailApp 的形狀 + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "note", "name": "筆記", "icon": "🗒️", "version": "0.1.0", + "has_ui": true, "ui_html": "
hi
", + "workflows": []map[string]string{{"name": "notes-create", "description": "寫入一則筆記"}}, + "actions": []string{"notes-create"}, + }) + }) + return httptest.NewServer(mux) +} + +func (f *fakeInstance) authed(r *http.Request) bool { + tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + return f.sessionToken != "" && tok == f.sessionToken +} + +// ── 驗收條件 1:清單來自實例,兩條路都通 ──────────────────────────────── + +func TestListApps_UsesPortalSessionWhenAvailable(t *testing.T) { + tempHome(t) + fi := &fakeInstance{sessionToken: "sess-a", password: "pw", apiKey: "ns-test"} + srv := fi.server(t) + defer srv.Close() + writeCfgWithAccount(t, srv.URL, accountCfg{ + InstanceName: "我的庫", Email: "a@b.c", APIKey: "ns-test", + PortalSession: "sess-a", PortalSessionExp: 0, + }) + + got := (&App{}).ListApps(0) + if got.Error != "" { + t.Fatalf("不該有錯:%s", got.Error) + } + if got.Source != "session" { + t.Fatalf("該走 portal session,實際走 %q", got.Source) + } + if len(got.Apps) != 2 || got.Apps[0].ID != "note" || got.Apps[0].Icon != "🗒️" || !got.Apps[0].HasUI { + t.Fatalf("清單沒有原樣接住實例的回應:%+v", got.Apps) + } + if got.Account != "我的庫" { + t.Fatalf("知識庫名字不對:%q", got.Account) + } +} + +func TestListApps_FallsBackToApiKeyWithoutSession(t *testing.T) { + tempHome(t) + fi := &fakeInstance{sessionToken: "", password: "pw", apiKey: "ns-test"} + srv := fi.server(t) + defer srv.Close() + // 完全沒有 session(=剛升級上來、或從沒登入過)——九宮格照樣要看得到真資料 + writeCfgWithAccount(t, srv.URL, accountCfg{Email: "a@b.c", APIKey: "ns-test"}) + + got := (&App{}).ListApps(0) + if got.Error != "" || got.Source != "apikey" || len(got.Apps) != 2 { + t.Fatalf("沒有 session 時該退回 API key 並照樣列出來:%+v", got) + } +} + +func TestListApps_ExpiredSessionIsClearedAndListStillWorks(t *testing.T) { + tempHome(t) + // 實例已經不認這張 session(f.sessionToken 與 config 裡那張不同) + fi := &fakeInstance{sessionToken: "sess-new", password: "pw", apiKey: "ns-test"} + srv := fi.server(t) + defer srv.Close() + writeCfgWithAccount(t, srv.URL, accountCfg{ + Email: "a@b.c", APIKey: "ns-test", PortalSession: "sess-old", + }) + + got := (&App{}).ListApps(0) + if got.Error != "" || got.Source != "apikey" || len(got.Apps) != 2 { + t.Fatalf("session 過期時清單仍該看得到(退回 API key):%+v", got) + } + // 已知無效的 token 要被丟掉,否則下一次還會再撞一次 401 + cfg, _ := loadCfg() + if cfg.Accounts[0].PortalSession != "" { + t.Fatalf("過期的 session 沒有被清掉:%q", cfg.Accounts[0].PortalSession) + } +} + +// 🔴「問不到」不准偽裝成「一個都沒裝」——實例 5xx 時不准悄悄退回 API key 假裝正常。 +func TestListApps_InstanceErrorIsNotDisguisedAsEmpty(t *testing.T) { + tempHome(t) + fi := &fakeInstance{sessionToken: "sess-a", password: "pw", apiKey: "ns-test", listStatus: 500} + srv := fi.server(t) + defer srv.Close() + writeCfgWithAccount(t, srv.URL, accountCfg{Email: "a@b.c", APIKey: "ns-test", PortalSession: "sess-a"}) + + got := (&App{}).ListApps(0) + if got.Error == "" { + t.Fatalf("實例回 500 卻沒有回報錯誤:%+v", got) + } + if len(got.Apps) != 0 { + t.Fatalf("出錯時不該給半份清單:%+v", got.Apps) + } +} + +func TestListApps_UnknownAccountSaysSo(t *testing.T) { + tempHome(t) + writeCfgWithAccount(t, "https://example.invalid", accountCfg{Email: "a@b.c", APIKey: "k"}) + if got := (&App{}).ListApps(7); got.Error == "" { + t.Fatalf("越界的索引該回錯,不是空清單") + } +} + +// ── 驗收條件 2:點進去能用 ─────────────────────────────────────────────── + +func TestGetApp_ReturnsDetailWithUI(t *testing.T) { + tempHome(t) + fi := &fakeInstance{sessionToken: "sess-a", password: "pw", apiKey: "ns-test"} + srv := fi.server(t) + defer srv.Close() + writeCfgWithAccount(t, srv.URL, accountCfg{Email: "a@b.c", APIKey: "ns-test", PortalSession: "sess-a"}) + + d := (&App{}).GetApp(0, "note") + if d.Error != "" || d.NeedsLogin { + t.Fatalf("不該有錯也不該要登入:%+v", d) + } + if !d.HasUI || d.UIHtml != "
hi
" { + t.Fatalf("自帶畫面沒接住:%+v", d) + } + if len(d.Workflows) != 1 || d.Workflows[0].Name != "notes-create" { + t.Fatalf("工作流清單沒接住:%+v", d.Workflows) + } + if len(d.Actions) != 1 || d.Actions[0] != "notes-create" { + t.Fatalf("動作白名單沒接住:%+v", d.Actions) + } +} + +// 沒有 session ⇒ 回 NeedsLogin(要長登入框),不是回一句錯誤。 +// 這是刻意的:上游的 API key 面**沒有**詳情端點(routes/apps.ts 只有 install/list/delete), +// 所以「看詳情」在協定上就只有 portal session 那一條路。 +func TestGetApp_WithoutSessionAsksForLoginNotError(t *testing.T) { + tempHome(t) + fi := &fakeInstance{sessionToken: "", password: "pw", apiKey: "ns-test"} + srv := fi.server(t) + defer srv.Close() + writeCfgWithAccount(t, srv.URL, accountCfg{Email: "a@b.c", APIKey: "ns-test"}) + + d := (&App{}).GetApp(0, "note") + if !d.NeedsLogin { + t.Fatalf("沒有 session 時該回 NeedsLogin:%+v", d) + } + if d.Error != "" { + t.Fatalf("這不是錯誤,是還差一步:%q", d.Error) + } + if d.Email != "a@b.c" { + t.Fatalf("登入框要能預填帳號:%q", d.Email) + } +} + +func TestPortalLogin_StoresSessionNotPassword(t *testing.T) { + tempHome(t) + fi := &fakeInstance{sessionToken: "", password: "pw", apiKey: "ns-test"} + srv := fi.server(t) + defer srv.Close() + writeCfgWithAccount(t, srv.URL, accountCfg{Email: "a@b.c", APIKey: "ns-test"}) + + if err := (&App{}).PortalLogin(0, "wrong"); err == nil { + t.Fatalf("密碼錯了應該要失敗") + } + if err := (&App{}).PortalLogin(0, "pw"); err != nil { + t.Fatalf("密碼對了卻失敗:%v", err) + } + raw, err := os.ReadFile(configPath()) + if err != nil { + t.Fatal(err) + } + // 🔴 密碼零落地:整份 config 裡不准出現密碼字串 + if strings.Contains(string(raw), "pw\"") || strings.Contains(string(raw), "\"password\"") { + t.Fatalf("密碼被寫進 config 了:%s", raw) + } + cfg, _ := loadCfg() + if cfg.Accounts[0].PortalSession == "" || cfg.Accounts[0].PortalSessionExp == 0 { + t.Fatalf("session 沒有存起來:%+v", cfg.Accounts[0]) + } + // 存了 session 之後,詳情就拿得到了 + if d := (&App{}).GetApp(0, "note"); d.NeedsLogin || d.Error != "" { + t.Fatalf("登入之後詳情還是拿不到:%+v", d) + } +} + +func TestRunAppAction_HitsInstanceAndCarriesPayload(t *testing.T) { + tempHome(t) + fi := &fakeInstance{sessionToken: "sess-a", password: "pw", apiKey: "ns-test"} + srv := fi.server(t) + defer srv.Close() + writeCfgWithAccount(t, srv.URL, accountCfg{Email: "a@b.c", APIKey: "ns-test", PortalSession: "sess-a"}) + + out, err := (&App{}).RunAppAction(0, "note", "notes-create", `{"text":"嗨"}`) + if err != nil { + t.Fatalf("動作失敗:%v", err) + } + if !strings.Contains(out, "blk_1") { + t.Fatalf("沒有把實例的結果原樣帶回來:%s", out) + } + if fi.gotAction != "notes-create" || fi.gotPayload["text"] != "嗨" { + t.Fatalf("payload 沒送到:%q %+v", fi.gotAction, fi.gotPayload) + } +} + +// 白名單是**實例**裁決的(K6)——桌面端不複製一份判斷,只負責把實例的話原樣說出來。 +func TestRunAppAction_WhitelistIsDecidedByInstance(t *testing.T) { + tempHome(t) + fi := &fakeInstance{sessionToken: "sess-a", password: "pw", apiKey: "ns-test"} + srv := fi.server(t) + defer srv.Close() + writeCfgWithAccount(t, srv.URL, accountCfg{Email: "a@b.c", APIKey: "ns-test", PortalSession: "sess-a"}) + + _, err := (&App{}).RunAppAction(0, "note", "rm-rf", "") + if err == nil { + t.Fatalf("不在白名單的動作該失敗") + } + if !strings.Contains(err.Error(), "白名單") { + t.Fatalf("該把實例的原話帶回來,實際:%v", err) + } +} + +// ── 純函式 ────────────────────────────────────────────────────────────── + +func TestParseAppList_FallsBackToIdWhenNameMissing(t *testing.T) { + apps := parseAppList([]byte(`{"apps":[{"id":"x","name":"","icon":"","has_ui":false,"version":"1"}]}`)) + if len(apps) != 1 || apps[0].Name != "x" { + t.Fatalf("沒有名字時該退回 id:%+v", apps) + } +} + +func TestUrlPathEscape(t *testing.T) { + for in, want := range map[string]string{ + "note": "note", + "a/b": "a%2Fb", + "a b": "a%20b", + "../etc": "..%2Fetc", + "筆記": "%E7%AD%86%E8%A8%98", + } { + if got := urlPathEscape(in); got != want { + t.Fatalf("urlPathEscape(%q)=%q,想要 %q", in, got, want) + } + } +} diff --git a/cmd/arcrun-app/check-launcher.mjs b/cmd/arcrun-app/check-launcher.mjs new file mode 100644 index 0000000..b75da2c --- /dev/null +++ b/cmd/arcrun-app/check-launcher.mjs @@ -0,0 +1,284 @@ +// check-launcher.mjs — App 啟動器的**看得見的驗收**(inkstone/arcrun-rag#137) +// +// 🔴 leo 2026-08-04:「既然是 Web,你做的時候**無法檢視**?」 +// ⇒ 這支就是那句話對 App 啟動器的兌現:真的把 frontend/dist 渲染出來、 +// 真的按下去、真的量畫面上有什麼,而不是「看程式碼覺得應該會動」。 +// +// 與旁邊 check-render.sh 的分工: +// · check-render.sh 量 CIS 的像素(lockup 接合/深色模式)——只跑得動 macOS 的 Chrome +// · 本檔 驗 App 啟動器的**行為**(九宮格/點進去/sandbox 橋/既有頁沒壞) +// 用 playwright 的 chromium,macOS/Linux/CI 都跑得動 +// +// 跑法:`node check-launcher.mjs`(沒裝 playwright 就自己跳過,不擋別人的流程) +// 產出:截圖在 /tmp/arcrun-launcher-shots/,每一條檢查印一行 ✅/❌ +// +// ⚠️ 這支驗的是**前端這一半**(畫面、互動、sandbox 邊界)。後端那一半 +// (真的去問實例、session/API key 兩條路、白名單由實例裁決)在 apps_test.go, +// 那裡打的是照上游原始碼寫的假實例。**兩邊都綠才算前後端一體。** +import http from 'node:http'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const dist = path.join(here, 'frontend', 'dist'); +const shots = '/tmp/arcrun-launcher-shots'; + +// playwright 從哪裡來:本地 node_modules 優先,其次全域安裝(CI/雲端常見)。 +// CommonJS 包從 ESM import 進來時,具名匯出可能只在 default 上,兩種都接。 +async function loadChromium() { + const tries = ['playwright', '/opt/node22/lib/node_modules/playwright/index.js']; + for (const spec of tries) { + try { + const m = await import(spec); + const c = m.chromium || (m.default && m.default.chromium); + if (c) return c; + } catch { /* 下一個 */ } + } + return null; +} +const chromium = await loadChromium(); +if (!chromium) { + console.log('⚠️ 沒有 playwright,跳過畫面驗收(npm i -D playwright && npx playwright install chromium)'); + process.exit(0); +} +if (!fs.existsSync(path.join(dist, 'index.html'))) { + console.error('❌ 沒有 frontend/dist——先 npm --prefix frontend run build'); + process.exit(1); +} + +// ── 靜態伺服器(dist 原樣,不改任何一個 byte)── +const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png' }; +const srv = http.createServer((req, res) => { + const rel = decodeURIComponent(req.url.split('?')[0]); + const f = path.join(dist, rel === '/' ? 'index.html' : rel); + if (!f.startsWith(dist) || !fs.existsSync(f) || fs.statSync(f).isDirectory()) { res.writeHead(404).end(); return; } + res.writeHead(200, { 'Content-Type': MIME[path.extname(f)] || 'application/octet-stream' }); + fs.createReadStream(f).pipe(res); +}); +await new Promise((r) => srv.listen(0, '127.0.0.1', r)); +const origin = `http://127.0.0.1:${srv.address().port}`; + +// ── 假後端:形狀逐欄對齊 apps.go 的 UIAppList/UIAppDetail ── +// +// 🔴 這裡**不是**在假裝 Go 那半會怎麼運作(那由 apps_test.go 打真 HTTP 驗)。 +// 這裡只回「Go 已經回過來的東西長什麼樣」,讓畫面有東西可畫。 +const mock = (scenario) => ` +const scenario = ${JSON.stringify(scenario)}; +window.__scenario = scenario; +window.__calls = []; +const APPS = [ + { id: 'note', name: '筆記', icon: '🗒️', hasUi: true, version: '0.1.0' }, + { id: 'weekly', name: '週報', icon: '📊', hasUi: false, version: '0.2.0' }, +]; +const DETAIL = { + note: { id:'note', name:'筆記', icon:'🗒️', version:'0.1.0', hasUi:true, + uiHtml: '

河道

' +
+      'document.getElementById("go").onclick=async()=>{' +
+      'const r=await window.arcrunApp.action("notes-create",{text:"嗨"});' +
+      'document.getElementById("out").textContent=JSON.stringify(r);};' +
+      'document.title="app-ui-loaded";' +
+      'window.__peek=(()=>{try{return typeof parent.go}catch(e){return "BLOCKED:"+e.name}})();' +
+      '',
+    workflows:[{name:'notes-create',description:'寫入一則筆記'}], actions:['notes-create'] },
+  weekly: { id:'weekly', name:'週報', icon:'📊', version:'0.2.0', hasUi:false, uiHtml:'',
+    workflows:[{name:'weekly-report',description:'每週一產週報'}], actions:['weekly-report'] },
+};
+const ACCOUNTS = [{
+  name:'我的知識庫', host:'arcrun-cypher-executor.demo.workers.dev', folders:[{path:'/Users/leo/Notes',accIdx:0}],
+  cloudVerKnown:true, cloudVerStale:false, cloudVerMine:'1.4.42', email:'leo@example.com',
+}];
+window.go = { main: { App: {
+  GetState: async () => ({
+    version:'v0.18.34', statusBig:'看守中 · 資料夾有變動就會自動整理', statusSub:'上次檢查 10:21',
+    syncing:false, engine:'workers-ai', geminiKey:'',
+    accounts: scenario === 'noaccount' ? [] : ACCOUNTS,
+    steps:[{title:'看守資料夾',meta:'有變動就自動開始',state:'done'},
+           {title:'發現變化',meta:'上次 10:20',state:'done'},
+           {title:'用 AI 整理成知識卡',state:'done'},
+           {title:'上傳到你的知識庫',meta:'上次 3 份',state:'done'}],
+    skipped:null, engineTrouble:false, logFolder:'/Users/leo/.arcrun-rag',
+    progress:{total:120,done:117,pending:3,cantSync:0,groups:[]}, quota:null,
+  }),
+  ListApps: async (i) => {
+    window.__calls.push(['ListApps', i]);
+    if (scenario === 'listerror') return { accIdx:i, account:'我的知識庫', apps:[], error:'連不上這個知識庫——請確認網路正常' };
+    if (scenario === 'noapps')    return { accIdx:i, account:'我的知識庫', apps:[], error:'', source:'session' };
+    return { accIdx:i, account:'我的知識庫', host:'demo', apps:APPS, error:'', source:'session' };
+  },
+  GetApp: async (i, id) => {
+    window.__calls.push(['GetApp', i, id]);
+    if (scenario === 'needlogin') return { id, name:'筆記', icon:'🗒️', needsLogin:true, email:'leo@example.com', workflows:[], actions:[] };
+    return DETAIL[id];
+  },
+  RunAppAction: async (i, id, action, payload) => {
+    window.__calls.push(['RunAppAction', i, id, action, payload]);
+    if (action !== 'notes-create' && action !== 'weekly-report') throw new Error('這個動作不在這個 App 的白名單內');
+    return JSON.stringify({ ok:true, result:{ id:'blk_1', echo: JSON.parse(payload||'{}') } });
+  },
+  PortalLogin: async (i, pw) => { window.__calls.push(['PortalLogin', i]); if (pw !== 'pw') throw new Error('email 或密碼錯誤'); },
+  SyncNow: async () => {}, PickFolder: async () => '', AddFolder: async () => {}, RemoveFolder: async () => {},
+  SetAI: async () => {}, OpenURL: () => { window.__calls.push(['OpenURL']); }, OpenLogFolder: async () => {},
+  Connect: async () => {}, CheckUpdate: async () => ({ latest:'v0.18.34' }),
+  DownloadUpdate: async () => ({}), ApplyUpdate: async () => {}, ExportDiagnostics: async () => '',
+}}};
+`;
+
+let fail = 0;
+const ok = (m) => console.log('  ✅ ' + m);
+const ng = (m) => { console.log('  ❌ ' + m); fail = 1; };
+const check = (cond, m) => (cond ? ok(m) : ng(m));
+
+fs.mkdirSync(shots, { recursive: true });
+const browser = await chromium.launch();
+
+async function open(scenario, theme = 'light') {
+  const ctx = await browser.newContext({ viewport: { width: 1100, height: 760 } });
+  const page = await ctx.newPage();
+  await page.addInitScript(`try{localStorage.setItem('arcrun_app_theme','${theme}')}catch(e){}`);
+  await page.addInitScript(mock(scenario));
+  const errs = [];
+  page.on('pageerror', (e) => errs.push(String(e)));
+  await page.goto(origin);
+  await page.waitForTimeout(400);
+  return { ctx, page, errs };
+}
+
+console.log('━━━ App 啟動器畫面驗收 ━━━');
+
+// ① 打開就是九宮格,磁磚是真資料(不是寫死的)
+{
+  const { ctx, page, errs } = await open('ok');
+  await page.waitForSelector('.appgrid', { timeout: 5000 }).catch(() => {});
+  const tiles = await page.$$eval('.appcell .nm', (n) => n.map((e) => e.textContent.trim()));
+  check(errs.length === 0, `啟動器沒有 JS 例外${errs.length ? '(' + errs[0] + ')' : ''}`);
+  check(await page.$('.appgrid') !== null, '打開就看到九宮格');
+  check(tiles.join('|') === '筆記|週報|加裝 App', `磁磚=實例回的那兩個 App + 加裝(實際:${tiles.join('|')})`);
+  check((await page.$eval('.apphead .s', (e) => e.textContent)).includes('2 個 App 已安裝'), '頁首說得出裝了幾個');
+  const calls = await page.evaluate(() => window.__calls.filter((c) => c[0] === 'ListApps').length);
+  check(calls === 1, `只問了實例一次,不是每秒輪詢(實際 ${calls} 次)`);
+  await page.waitForTimeout(2500);
+  const calls2 = await page.evaluate(() => window.__calls.filter((c) => c[0] === 'ListApps').length);
+  check(calls2 === 1, `2.5 秒後仍然只問過一次(實際 ${calls2} 次)— 沒有掛在每秒的 tick 上`);
+  await page.screenshot({ path: `${shots}/01-launcher-light.png` });
+  await ctx.close();
+}
+
+// ①b 深色模式也要對
+{
+  const { ctx, page } = await open('ok', 'dark');
+  await page.waitForSelector('.appgrid');
+  await page.screenshot({ path: `${shots}/02-launcher-dark.png` });
+  check(await page.getAttribute('html', 'data-theme') === 'dark', '深色模式渲染得出來');
+  await ctx.close();
+}
+
+// ② 點一個「有自帶畫面」的 App → 進 sandbox iframe,且橋真的通
+{
+  const { ctx, page, errs } = await open('ok');
+  await page.waitForSelector('.appgrid');
+  await page.click('[data-appopen="note"]');
+  await page.waitForSelector('#appFrame', { timeout: 5000 });
+  const frame = await (await page.$('#appFrame')).contentFrame();
+  await frame.waitForSelector('#go', { timeout: 5000 });
+  check(true, '點「筆記」進得去,App 自帶畫面渲染出來');
+  // 🔴 sandbox 邊界:iframe 裡的 script 不准碰得到 parent 的 window.go
+  //    (那上面掛著 RemoveFolder(takedown)/SetAI/Connect)
+  const peek = await frame.evaluate(() => window.__peek);
+  check(typeof peek === 'string' && peek.startsWith('BLOCKED'), `iframe 碰不到 parent 的 window.go(實際:${peek})`);
+  // 橋:iframe 按鈕 → postMessage → go.RunAppAction → 回到 iframe
+  await frame.click('#go');
+  await frame.waitForFunction(() => document.getElementById('out').textContent.length > 0, null, { timeout: 5000 });
+  const out = await frame.$eval('#out', (e) => e.textContent);
+  check(out.includes('blk_1'), `sandbox 橋走得通,結果回得到 App 畫面(實際:${out.slice(0, 80)})`);
+  const call = await page.evaluate(() => window.__calls.find((c) => c[0] === 'RunAppAction'));
+  check(call && call[3] === 'notes-create' && JSON.parse(call[4]).text === '嗨', 'payload 原樣送到後端');
+  check(errs.length === 0, `App 頁沒有 JS 例外${errs.length ? '(' + errs[0] + ')' : ''}`);
+  await page.screenshot({ path: `${shots}/03-app-ui.png` });
+  await ctx.close();
+}
+
+// ②b 沒有自帶畫面的 App → 工作流清單 +「現在執行」
+{
+  const { ctx, page } = await open('ok');
+  await page.waitForSelector('.appgrid');
+  await page.click('[data-appopen="weekly"]');
+  await page.waitForSelector('.wfitem', { timeout: 5000 });
+  check(await page.$eval('.wfitem .nm', (e) => e.textContent) === 'weekly-report', '沒有畫面的 App 列出它的工作流');
+  await page.click('[data-apprun="weekly-report"]');
+  await page.waitForFunction(() => document.querySelector('.wfitem .out').textContent.includes('完成'), null, { timeout: 5000 });
+  check(true, '「現在執行」按得動,結果顯示在那一列底下');
+  await page.screenshot({ path: `${shots}/04-app-workflows.png` });
+  await ctx.close();
+}
+
+// ③ 三種「不是正常」的狀態都要說人話,且分得出來
+{
+  const { ctx, page } = await open('noapps');
+  await page.waitForSelector('.appgrid');
+  const txt = await page.textContent('#page');
+  check(txt.includes('這個知識庫還沒有 App'), '一個都沒裝 → 說「還沒有 App」並教怎麼裝');
+  check(!txt.includes('連不上'), '一個都沒裝時不准講成連線失敗');
+  await page.screenshot({ path: `${shots}/05-empty.png` });
+  await ctx.close();
+}
+{
+  const { ctx, page } = await open('listerror');
+  await page.waitForTimeout(300);
+  const txt = await page.textContent('#page');
+  check(txt.includes('暫時看不到') && txt.includes('連不上'), '問不到 → 說「問不到」並帶實例的原話');
+  check(!txt.includes('還沒有 App'), '問不到時不准講成「一個都沒裝」');
+  await page.screenshot({ path: `${shots}/06-error.png` });
+  await ctx.close();
+}
+{
+  const { ctx, page } = await open('noaccount');
+  await page.waitForTimeout(300);
+  const txt = await page.textContent('#page');
+  check(txt.includes('歡迎使用 Arcrun') || txt.includes('還沒有連上知識庫'), '沒連任何實例 → 說人話(連線精靈),不是空白');
+  await page.screenshot({ path: `${shots}/07-noaccount.png` });
+  await ctx.close();
+}
+{
+  const { ctx, page } = await open('needlogin');
+  await page.waitForSelector('.appgrid');
+  await page.click('[data-appopen="note"]');
+  await page.waitForSelector('#apPw', { timeout: 5000 });
+  check(await page.inputValue('#apEmail') === 'leo@example.com', 'session 過期 → 長登入框並預填帳號');
+  await page.screenshot({ path: `${shots}/08-needlogin.png` });
+  await ctx.close();
+}
+
+// ④ 驗收條件 3:現有功能一項都沒少
+{
+  const { ctx, page, errs } = await open('ok');
+  await page.waitForSelector('.appgrid');
+  const navs = await page.$$eval('#side .nav .nm', (n) => n.map((e) => e.textContent.trim()));
+  for (const want of ['App 界面', '首頁', '我的知識庫', 'AI 設定', '版本與更新']) {
+    check(navs.includes(want), `側欄仍有「${want}」`);
+  }
+  const pages = [
+    ['home', '現在的狀態'],
+    ['lib:0', '開啟知識庫網頁'],
+    ['ai', '用哪個 AI 幫你整理文件'],
+    ['update', '版本與更新'],
+  ];
+  for (const [p, marker] of pages) {
+    await page.click(`#side .nav[data-p="${p}"]`);
+    await page.waitForTimeout(200);
+    const t = await page.textContent('#page');
+    check(t.includes(marker), `「${p}」頁還在(找得到「${marker}」)`);
+  }
+  // 全站頁首的同步狀態與「立刻同步」在每一頁都看得到(首頁換走了它們也沒消失)
+  check((await page.textContent('#statusBig')).includes('看守中'), '同步狀態在全站頁首,換頁不會不見');
+  check(await page.$('#btnSync') !== null, '「立刻同步」按鈕還在');
+  check(errs.length === 0, `逛完全部頁面沒有 JS 例外${errs.length ? '(' + errs[0] + ')' : ''}`);
+  await page.screenshot({ path: `${shots}/09-existing-update.png` });
+  await ctx.close();
+}
+
+await browser.close();
+srv.close();
+console.log(`\n截圖:${shots}`);
+console.log(fail ? '\n❌ 畫面驗收未過' : '\n✅ 畫面驗收全過');
+process.exit(fail);
diff --git a/cmd/arcrun-app/check-remove-cleanup.sh b/cmd/arcrun-app/check-remove-cleanup.sh
new file mode 100755
index 0000000..9843b14
--- /dev/null
+++ b/cmd/arcrun-app/check-remove-cleanup.sh
@@ -0,0 +1,130 @@
+#!/usr/bin/env bash
+# check-remove-cleanup.sh — 驗**使用者真的會走的那條路**:
+# 從各庫頁按「移除」→ 對話框 → 勾「順便清掉」→ 看到清單 → 按確定(arcrun-rag#138)。
+#
+# 🔴 為什麼要這一支:Go 那邊的迴歸網只證明「函式做對事」,證明不了
+#    「按鈕存在、清單真的攤出來、第四個參數真的傳下去」。#138 的驗收條件裡
+#    「使用者要能在動手前看到將要刪掉哪些東西」**只有前端驗得到**。
+#    (同 check-render.sh 的理由:改完 UI 不准拿沒看過的畫面交件。)
+#
+# 做法沿用 check-render.sh:把 dist 複製到暫存、插一支 mock.js 假扮 window.go,
+# 再用 headless Chrome 真的載入、真的點下去,最後 --dump-dom 讀結果。
+set -uo pipefail
+cd "$(dirname "$0")"
+CHROME="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
+[ -x "$CHROME" ] || { echo "❌ 找不到 Chrome,跳過"; exit 0; }
+[ -d frontend/dist ] || { echo "❌ 沒有 frontend/dist——先 (cd frontend && npm run build)"; exit 1; }
+
+W=$(mktemp -d)
+SRV_PID=""
+cleanup() { rm -rf "$W"; [ -n "$SRV_PID" ] && kill "$SRV_PID" 2>/dev/null; }
+trap cleanup EXIT
+lsof -ti tcp:8798 2>/dev/null | xargs -r kill 2>/dev/null; sleep 0.3
+cp -R frontend/dist/* "$W/"
+
+cat > "$W/mock.js" <<'JS'
+// 假的 window.go:一個知識庫、一個看守中的資料夾,PlanFolderCleanup 回一份
+// 形狀與 collector.CleanupPlan 完全一致的清單(含「留著不動」那一半)。
+window.__calls = [];
+window.go = { main: { App: {
+  GetState: async () => ({
+    version: 'v0', statusBig: '看守中', statusSub: '', syncing: false,
+    engine: 'workers-ai', geminiKey: '', extractedOK: 0,
+    accounts: [{ name: '測試庫', host: 'arcrun-cypher-executor.example.workers.dev',
+      folders: [{ path: '/kb/pms', accIdx: 0, retiring: false }] }],
+  }),
+  PlanFolderCleanup: async (acc, path) => {
+    window.__calls.push(['PlanFolderCleanup', acc, path]);
+    return {
+      root: path, files: 52, bytes: 1234,
+      remove: [
+        { rel: '.arcrun-rag', is_dir: true, kind: 'workspace', evidence: '依據甲', files: 1, bytes: 10 },
+        { rel: '.wiki', is_dir: true, kind: 'wiki-dir', evidence: '依據乙', files: 8, bytes: 100 },
+        { rel: 'docs/.wiki', is_dir: true, kind: 'wiki-dir', evidence: '依據乙', files: 43, bytes: 900 },
+      ],
+      keep: [{ rel: 'pms_v1_legacy', reason: '這個資料夾也還在同步清單裡' }],
+    };
+  },
+  RemoveFolder: async (acc, path, takedown, cleanupLocal) => {
+    window.__calls.push(['RemoveFolder', acc, path, takedown, cleanupLocal]);
+  },
+  SyncNow: async () => {}, PickFolder: async () => '', AddFolder: async () => {},
+  SetAI: async () => {}, OpenURL: () => {}, Connect: async () => {},
+  CheckUpdate: async () => ({}), OpenLogFolder: () => {}, LogFolderPath: () => '',
+  ListApps: async () => ({ apps: [] }),
+} } };
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const say = (k, v) => {
+  const d = document.createElement('div');
+  d.setAttribute('data-check', k); d.textContent = String(v);
+  document.body.appendChild(d);
+};
+
+addEventListener('DOMContentLoaded', async () => {
+  await sleep(600);                       // 等 main.js 拿到 state、畫完側欄
+  // 走到各庫頁(使用者點側邊欄那個庫)
+  const nav = [...document.querySelectorAll('.nav')].find((b) => (b.dataset.p || '').startsWith('lib:'));
+  if (nav) nav.click(); else location.hash = '';
+  await sleep(300);
+
+  const rm = document.querySelector('[data-rm]');
+  say('有移除按鈕', !!rm);
+  if (!rm) { say('done', 1); return; }
+  rm.click();
+  await sleep(200);
+
+  const box = document.getElementById('rmClean');
+  say('有清理勾選框', !!box);
+  say('預設不勾', box ? box.checked === false : false);
+  if (!box) { say('done', 1); return; }
+
+  box.checked = true; box.dispatchEvent(new Event('change'));
+  await sleep(400);
+  const planTxt = (document.getElementById('rmPlan') || {}).textContent || '';
+  say('清單有列出要刪的路徑', planTxt.includes('.arcrun-rag') && planTxt.includes('docs/.wiki'));
+  say('清單有列出留著不動的', planTxt.includes('pms_v1_legacy'));
+  say('清單有講幾個檔', planTxt.includes('52'));
+
+  document.getElementById('c2').click();
+  await sleep(400);
+  const call = window.__calls.find((c) => c[0] === 'RemoveFolder');
+  say('有呼叫 RemoveFolder', !!call);
+  say('第四個參數帶了 true', call ? call[4] === true : false);
+  say('路徑正確', call ? call[2] === '/kb/pms' : false);
+  say('done', 1);
+});
+JS
+
+python3 - "$W" <<'PY'
+import sys
+p = sys.argv[1] + '/index.html'
+s = open(p, encoding='utf-8').read()
+s = s.replace('src="/assets/', 'src="./assets/').replace('href="/assets/', 'href="./assets/')
+s = s.replace('\n