新增全新安裝的「可刪除預設庫」(leo 2026-08-08 拍板)
全新使用者第一次連上知識庫時,自動在 Documents 建一個 「Arcrun 範例庫(可刪除)」,裡面放三份 .md 示範內容, 不必自己選資料夾就能立刻走完「丟檔→知識卡→搜得到」。 四條紅線都有測試釘住(default_library_test.go/ default_library_e2e_test.go,含真的建 binary 跑 --dry-run 驗證): 1. 可刪——刪掉就真的消失,marker 檔擋住重種 2. 只有第一次——已有帳號的機器不會冒出來 3. 不污染——只在自己的資料夾裡寫檔 4. 不留幽靈資料——資料夾被刪後,config 殘留引用會被清掉(GetState 自我修復) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -481,6 +481,17 @@ func (a *App) GetState() UIState {
|
||||
st := UIState{Version: version}
|
||||
cfg, _ := loadCfg()
|
||||
|
||||
// 預設庫的實體資料夾若已經被使用者刪掉,把 config 裡的殘留引用清掉——
|
||||
// 不留幽靈資料、也不會讓 collector 每輪都對著不存在的資料夾報錯(見 default_library.go)。
|
||||
// 資料夾本身「會不會長回來」由 seedDefaultLibraryIfFirstEver 的 marker 檔擋住,這裡不會重種。
|
||||
if pruneMissingDefaultLibrary(cfg) {
|
||||
if err := saveCfg(cfg); err != nil {
|
||||
appLog("清理已刪除的預設庫引用失敗:%v", err)
|
||||
} else {
|
||||
restartWatch()
|
||||
}
|
||||
}
|
||||
|
||||
for i, acc := range cfg.Accounts {
|
||||
ui := UIAccount{Name: accountName(acc), Host: shortHost(acc.CypherURL)}
|
||||
for _, f := range acc.WatchFolders {
|
||||
|
||||
@@ -109,6 +109,7 @@ func (a *App) Connect(portalURL, email, password string) error {
|
||||
}
|
||||
// 不同實例=新增一個帳號。**不覆蓋舊帳號**——t86 事故的教訓:
|
||||
// 舊實例的資料夾若被帶進新實例,等於把別人的檔案同步到另一個知識庫(個資外洩)。
|
||||
wasFirstEver := len(cfg.Accounts) == 0 // 這台機器至今從沒連過任何知識庫帳號
|
||||
cfg.Accounts = append(cfg.Accounts, accountCfg{
|
||||
InstanceName: r.Config.InstanceName,
|
||||
Email: r.Config.Email,
|
||||
@@ -116,6 +117,11 @@ func (a *App) Connect(portalURL, email, password string) error {
|
||||
Namespace: r.Config.Namespace,
|
||||
APIKey: r.Config.Namespace,
|
||||
})
|
||||
if wasFirstEver {
|
||||
// leo 2026-08-08:全新安裝自動裝一個「可刪除的預設庫」,讓使用者不必
|
||||
// 自己選資料夾也能立刻看到「丟檔 → 知識卡 → 搜得到」整條路(見 default_library.go)。
|
||||
seedDefaultLibraryIfFirstEver(cfg, len(cfg.Accounts)-1)
|
||||
}
|
||||
return saveCfg(cfg)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package main
|
||||
|
||||
// default_library.go — 全新安裝的「可刪除預設庫」(leo 2026-08-08 拍板)
|
||||
//
|
||||
// leo 原話:「地端更新後,要裝一個 local 的 default 庫,他可以刪除。」
|
||||
// 具體化自更早的構想:「給他裝一個 Demo 同步文件夾在 Documents,裡面有幾個檔案
|
||||
// 說明 Arcrun 的豐功偉業……如果那個檔案被產出 wiki、可以 embed、可以搜,基本就
|
||||
// 整條通了,用戶連串一個資料夾都省了……不過,只有第一次安裝,然後他刪除,只要
|
||||
// 有掛用戶自己的資料夾,就不再出現。」
|
||||
//
|
||||
// 四條紅線:
|
||||
// 1. 可刪——用戶刪掉就真的消失,不自己長回來
|
||||
// 2. 只有第一次——一旦掛過自己的資料夾(=這台機器已經連過任何知識庫帳號),
|
||||
// 這個預設庫不再出現
|
||||
// 3. 不污染——只在自己的資料夾裡寫檔,乾淨拿走、不留殘骸
|
||||
// 4. 不產生幽靈資料——資料夾被刪掉後,config 裡的殘留引用也要清掉,
|
||||
// 不留「查得到但畫面永遠空」的紀錄
|
||||
//
|
||||
// 機制:
|
||||
// - marker 檔(default-library-seeded.json)是「只種一次」的唯一開關——
|
||||
// 一旦寫入就永遠不再重種,即使之後資料夾被刪、watch_folders 引用被清掉。
|
||||
// - seedDefaultLibraryIfFirstEver 只在 Connect() 新增「這台機器第一個帳號」時呼叫。
|
||||
// - pruneMissingDefaultLibrary 在 GetState()(前端每秒輪詢的入口)裡自我修復:
|
||||
// 發現預設庫的實體資料夾已經不在了,就把 watch_folders 裡的殘留路徑拿掉。
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
func defaultLibraryMarkerPath() string {
|
||||
return filepath.Join(appDir(), "default-library-seeded.json")
|
||||
}
|
||||
|
||||
type defaultLibraryMarker struct {
|
||||
Path string `json:"path"`
|
||||
SeededAt string `json:"seeded_at"`
|
||||
}
|
||||
|
||||
// defaultLibraryDirName 刻意把「可刪除」寫進資料夾名字本身——
|
||||
// 使用者不用讀任何說明文件,光看資料夾名稱就知道這是我們鋪的示範內容、可以放心刪。
|
||||
const defaultLibraryDirName = "Arcrun 範例庫(可刪除)"
|
||||
|
||||
func defaultLibraryPath() string {
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, "Documents", defaultLibraryDirName)
|
||||
}
|
||||
|
||||
// defaultLibraryFiles=種進預設庫的示範內容(.md,collector 一定讀得懂)。
|
||||
// 內容本身不重要,重要的是「丟檔案進去 → AI 整理成知識卡 → 搜得到」這條路走得通。
|
||||
var defaultLibraryFiles = map[string]string{
|
||||
"01-關於這個資料夾.md": `# 這個資料夾是什麼
|
||||
|
||||
這是 Arcrun 第一次安裝時自動幫你準備的示範資料夾,裡面幾份筆記已經在被整理成知識卡。
|
||||
|
||||
- 打開 Arcrun,應該很快就能在你的知識庫裡搜到「Arcrun」「知識卡」這些字。
|
||||
- 這個資料夾**可以直接刪掉**,刪掉就真的沒了,不會自己跑回來。
|
||||
- 如果你已經加了自己的資料夾,這個示範資料夾之後也不會再自動出現。
|
||||
- 想長期使用也可以——把你想存的檔案直接丟進來就行,跟其他資料夾一樣會被自動整理。
|
||||
`,
|
||||
"02-Arcrun-是什麼.md": `# Arcrun 是什麼
|
||||
|
||||
Arcrun 是一個跑在背景的小幫手:你把筆記、文件丟進你指定的資料夾,
|
||||
它會自動讀出內容、用 AI 整理成一張張「知識卡」,送進你自己的知識庫,
|
||||
之後不管在哪台裝置,打開知識庫網站就能直接搜到。
|
||||
|
||||
常見用法:
|
||||
- 開會筆記、專案文件放進同一個資料夾,事後用關鍵字就找得回來。
|
||||
- 支援 Markdown、純文字、Word、PowerPoint、PDF、CSV、Excel。
|
||||
- 掃描版 PDF(沒有文字層的圖片檔)目前還讀不了,其餘格式會自動處理。
|
||||
`,
|
||||
"03-怎麼使用.md": `# 怎麼使用
|
||||
|
||||
1. 把檔案拖進被監看的資料夾(就是這個資料夾,或是你之後自己加的資料夾)。
|
||||
2. Arcrun 會在背景發現變化、用 AI 整理成知識卡、上傳到你的知識庫,不用手動按什麼。
|
||||
3. 想馬上看到結果,也可以在 App 裡按「立刻同步」。
|
||||
4. 到你的知識庫網站搜尋,就能找到剛剛整理出來的內容。
|
||||
|
||||
如果額度用完或某個檔案讀不了,App 首頁會用白話告訴你發生什麼事、要不要動手——
|
||||
不會安靜地失敗、也不會丟裸露的錯誤碼給你看。
|
||||
`,
|
||||
}
|
||||
|
||||
// seedDefaultLibraryIfFirstEver 只在「這台機器第一次連上任何知識庫帳號」時跑一次
|
||||
// (呼叫端 Connect() 已保證:這是 cfg.Accounts 從空變成非空的那一次新增)。
|
||||
// marker 檔一旦寫入就是永久開關——不管之後資料夾或 watch_folders 引用發生什麼事,
|
||||
// 都不會再種第二次。
|
||||
func seedDefaultLibraryIfFirstEver(cfg *directConfig, accIdx int) {
|
||||
if _, err := os.Stat(defaultLibraryMarkerPath()); err == nil {
|
||||
return // 已經種過一次,不管現在還在不在,永遠不再種第二次
|
||||
}
|
||||
path := defaultLibraryPath()
|
||||
if err := writeDefaultLibraryContents(path); err != nil {
|
||||
appLog("預設庫建立失敗(不影響本次連線):%v", err)
|
||||
return
|
||||
}
|
||||
if accIdx >= 0 && accIdx < len(cfg.Accounts) {
|
||||
already := false
|
||||
for _, f := range cfg.Accounts[accIdx].WatchFolders {
|
||||
if f == path {
|
||||
already = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !already {
|
||||
cfg.Accounts[accIdx].WatchFolders = append(cfg.Accounts[accIdx].WatchFolders, path)
|
||||
sort.Strings(cfg.Accounts[accIdx].WatchFolders)
|
||||
}
|
||||
}
|
||||
marker := defaultLibraryMarker{Path: path, SeededAt: time.Now().Format(time.RFC3339)}
|
||||
if b, err := json.MarshalIndent(marker, "", " "); err == nil {
|
||||
if err := os.MkdirAll(appDir(), 0o755); err == nil {
|
||||
_ = os.WriteFile(defaultLibraryMarkerPath(), b, 0o644)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeDefaultLibraryContents 建資料夾+寫入示範檔案。已存在的檔案不覆寫
|
||||
// (理論上不會發生——marker 擋住重複呼叫——但求安全,不要覆蓋使用者可能已經改過的內容)。
|
||||
func writeDefaultLibraryContents(dir string) error {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
for name, body := range defaultLibraryFiles {
|
||||
p := filepath.Join(dir, name)
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
continue
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pruneMissingDefaultLibrary:預設庫的實體資料夾若已經被使用者刪掉,
|
||||
// 把 config 裡殘留的 watch_folders 路徑也一起清掉——不留「查得到但資料夾早就不在」
|
||||
// 的幽靈引用,也不會讓 collector 每輪都對著一個不存在的資料夾報錯。
|
||||
// 資料夾「會不會長回來」由 seedDefaultLibraryIfFirstEver 的 marker 檔擋住,
|
||||
// 這裡只負責把 config 清乾淨,不寫回任何實體檔案內容。
|
||||
// 回傳 changed=cfg 是否被修改(呼叫端據此決定要不要 saveCfg)。
|
||||
func pruneMissingDefaultLibrary(cfg *directConfig) bool {
|
||||
b, err := os.ReadFile(defaultLibraryMarkerPath())
|
||||
if err != nil {
|
||||
return false // 從沒種過,沒東西可清
|
||||
}
|
||||
var marker defaultLibraryMarker
|
||||
if jerr := json.Unmarshal(b, &marker); jerr != nil || marker.Path == "" {
|
||||
return false
|
||||
}
|
||||
if _, err := os.Stat(marker.Path); err == nil {
|
||||
return false // 資料夾還在,不用清
|
||||
}
|
||||
changed := false
|
||||
for i := range cfg.Accounts {
|
||||
keep := cfg.Accounts[i].WatchFolders[:0]
|
||||
for _, f := range cfg.Accounts[i].WatchFolders {
|
||||
if f == marker.Path {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
keep = append(keep, f)
|
||||
}
|
||||
cfg.Accounts[i].WatchFolders = keep
|
||||
}
|
||||
return changed
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDefaultLibraryEndToEndDryRun 是 default_library_test.go 的補強:那邊驗證的是
|
||||
// 「App 這一層的邏輯對不對」(單元層級),這支驗證的是**真的把同步引擎跑起來**,
|
||||
// 證明預設庫裡的檔案真的會被 collector 認成「要處理的事件」——也就是
|
||||
// 「丟檔 → 知識卡」那條路的前半段真的通,不是紙上談兵。
|
||||
// 用 --dry-run:collector 掃到 added 事件後回報 status="planned" 就收手,
|
||||
// 不會真的打雲端(沒有真的知識庫可以打),也不寫 manifest。
|
||||
func TestDefaultLibraryEndToEndDryRun(t *testing.T) {
|
||||
bin := filepath.Join(t.TempDir(), "arcrun-app-e2e")
|
||||
if out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput(); err != nil {
|
||||
t.Fatalf("編不出執行檔:%v\n%s", err, out)
|
||||
}
|
||||
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
|
||||
// ① 全新安裝、第一次連上知識庫 ⇒ 應該自動長出預設庫
|
||||
srv := fakeDaemonConfigServer(t, "https://instance-e2e.example.workers.dev")
|
||||
app := &App{}
|
||||
if err := app.Connect(srv.URL, "evan@example.com", "pw"); err != nil {
|
||||
t.Fatalf("Connect 失敗:%v", err)
|
||||
}
|
||||
libPath := defaultLibraryPath()
|
||||
|
||||
run := func(label string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command(bin, collectorModeFlag, "direct", "--once", "--dry-run", "--config", configPath())
|
||||
cmd.Env = append(os.Environ(), "HOME="+home, "USERPROFILE="+home)
|
||||
out, _ := cmd.CombinedOutput() // 非零結束碼也要看輸出,不提前判死
|
||||
t.Logf("=== %s ===\n%s", label, out)
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// ② 真的跑一輪:三份示範檔應該被辨識成 planned(=萃取管線願意收)
|
||||
out := run("① 全新安裝,預設庫存在")
|
||||
plannedCount := strings.Count(out, `"status": "planned"`)
|
||||
if plannedCount < len(defaultLibraryFiles) {
|
||||
t.Fatalf("預設庫的 %d 份示範檔應該全部被排進處理佇列(status=planned),"+
|
||||
"實際只看到 %d 次,輸出:\n%s", len(defaultLibraryFiles), plannedCount, out)
|
||||
}
|
||||
if strings.Contains(out, "缺必填欄位") {
|
||||
t.Fatalf("設定缺必填欄位:%s", out)
|
||||
}
|
||||
|
||||
// ③ 使用者把預設庫整個刪掉
|
||||
if err := os.RemoveAll(libPath); err != nil {
|
||||
t.Fatalf("刪除失敗:%v", err)
|
||||
}
|
||||
if _, err := os.Stat(libPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("刪除沒生效:%v", err)
|
||||
}
|
||||
|
||||
// App 下一次讀狀態時的自我修復(等同 GetState 裡的那段):清掉殘留引用
|
||||
cfg, err := loadCfg()
|
||||
if err != nil {
|
||||
t.Fatalf("讀不回設定:%v", err)
|
||||
}
|
||||
if !pruneMissingDefaultLibrary(cfg) {
|
||||
t.Fatal("應該偵測到預設庫已被刪除")
|
||||
}
|
||||
if err := saveCfg(cfg); err != nil {
|
||||
t.Fatalf("存檔失敗:%v", err)
|
||||
}
|
||||
|
||||
// ④ 再跑一輪:不該再處理那三份檔(資料夾不在了),也不該把資料夾生回來
|
||||
out2 := run("② 刪除後再跑一輪")
|
||||
if strings.Contains(out2, `"status": "planned"`) {
|
||||
t.Fatalf("刪除後不該還有 planned 事件,輸出:\n%s", out2)
|
||||
}
|
||||
if _, err := os.Stat(libPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("預設庫刪掉後又被同步引擎生回來了:%v", err)
|
||||
}
|
||||
|
||||
// ⑤ marker 檔還在 ⇒ 就算再跑一次種子邏輯,也不會再出現(紅線②③④一次證完)
|
||||
seedDefaultLibraryIfFirstEver(cfg, 0)
|
||||
if _, err := os.Stat(libPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("種子邏輯把已刪除的預設庫又生回來了:%v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeDaemonConfigServer 假的知識庫登入端點,回傳固定的 daemonConfigResp,
|
||||
// 讓測試能真的走過 App.Connect() 而不必打真的雲端。
|
||||
//
|
||||
// 🔴 用 TLS server 不是隨便選的:normalizePortalURL()(connect.go)不管使用者貼的是
|
||||
// http:// 還是 https://,一律吐回 "https://" + host(現實世界的網址全是 https,這樣
|
||||
// 才不必逼使用者記得打 https)——所以假伺服器也一定要是 TLS,用一般 httptest.NewServer
|
||||
// 會被硬轉成 https:// 打過去、連不上。同時把 http.DefaultTransport 換成信任這顆測試憑證
|
||||
// 的版本(fetchConfigByLogin 的 http.Client{} 沒指定 Transport ⇒ 用的正是這個全域預設值),
|
||||
// 測試結束就還原,不影響其他測試或正式行為。
|
||||
func fakeDaemonConfigServer(t *testing.T, cypherURL string) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/portal/daemon/config", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(daemonConfigResp{
|
||||
Success: true,
|
||||
Config: daemonConfig{
|
||||
CypherURL: cypherURL,
|
||||
Namespace: "abc123",
|
||||
Library: "lib1",
|
||||
Email: "evan@example.com",
|
||||
InstanceName: "evan-的知識庫",
|
||||
},
|
||||
})
|
||||
})
|
||||
srv := httptest.NewTLSServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
origTransport := http.DefaultTransport
|
||||
http.DefaultTransport = srv.Client().Transport
|
||||
t.Cleanup(func() { http.DefaultTransport = origTransport })
|
||||
|
||||
return srv
|
||||
}
|
||||
|
||||
// TestDefaultLibrarySeededOnFirstConnect 釘住紅線①③:全新安裝、第一次連上知識庫,
|
||||
// 應該自動出現一個可刪除的預設庫——裡面有真的檔案(丟檔→知識卡→搜得到那條路走得通),
|
||||
// 而且只在這個資料夾自己底下寫東西,不污染 Documents 的其他地方。
|
||||
func TestDefaultLibrarySeededOnFirstConnect(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
|
||||
srv := fakeDaemonConfigServer(t, "https://instance-a.example.workers.dev")
|
||||
app := &App{}
|
||||
if err := app.Connect(srv.URL, "evan@example.com", "pw"); err != nil {
|
||||
t.Fatalf("Connect 失敗:%v", err)
|
||||
}
|
||||
|
||||
cfg, err := loadCfg()
|
||||
if err != nil {
|
||||
t.Fatalf("讀不回設定:%v", err)
|
||||
}
|
||||
if len(cfg.Accounts) != 1 {
|
||||
t.Fatalf("應該只有一個帳號,實際 %d 個", len(cfg.Accounts))
|
||||
}
|
||||
|
||||
wantPath := defaultLibraryPath()
|
||||
found := false
|
||||
for _, f := range cfg.Accounts[0].WatchFolders {
|
||||
if f == wantPath {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("預設庫 %s 沒有被加進 watch_folders:%v", wantPath, cfg.Accounts[0].WatchFolders)
|
||||
}
|
||||
|
||||
// 資料夾真的存在、裡面有真的檔案(不是空殼)
|
||||
entries, err := os.ReadDir(wantPath)
|
||||
if err != nil {
|
||||
t.Fatalf("預設庫資料夾沒有被建出來:%v", err)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
t.Fatal("預設庫資料夾是空的,等於沒有東西可以被整理成知識卡")
|
||||
}
|
||||
for _, e := range entries {
|
||||
if filepath.Ext(e.Name()) != ".md" {
|
||||
t.Fatalf("預設庫檔案 %s 不是 collector 讀得懂的格式(見 scan.go allowedExt)", e.Name())
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(wantPath, e.Name()))
|
||||
if err != nil || len(b) == 0 {
|
||||
t.Fatalf("%s 讀不到內容或是空檔——不准放空檔/佔位符", e.Name())
|
||||
}
|
||||
}
|
||||
|
||||
// 不污染:Documents 底下只多出這一個資料夾,沒有散落在別處的殘留檔案
|
||||
docsEntries, err := os.ReadDir(filepath.Join(home, "Documents"))
|
||||
if err != nil {
|
||||
t.Fatalf("讀不到 Documents:%v", err)
|
||||
}
|
||||
if len(docsEntries) != 1 || docsEntries[0].Name() != defaultLibraryDirName {
|
||||
names := []string{}
|
||||
for _, e := range docsEntries {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
t.Fatalf("Documents 底下應該只有預設庫這一個資料夾,實際:%v", names)
|
||||
}
|
||||
|
||||
// marker 檔存在,代表「已經種過一次」
|
||||
if _, err := os.Stat(defaultLibraryMarkerPath()); err != nil {
|
||||
t.Fatalf("marker 檔沒寫出來:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultLibraryDeletedDoesNotComeBack 釘住紅線①④:使用者把預設庫整個刪掉之後,
|
||||
// ① config 裡的殘留引用要被清乾淨(不留幽靈資料)
|
||||
// ② 就算之後又跑一次「種子」邏輯,也絕對不會把資料夾或引用生回來。
|
||||
func TestDefaultLibraryDeletedDoesNotComeBack(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
|
||||
srv := fakeDaemonConfigServer(t, "https://instance-b.example.workers.dev")
|
||||
app := &App{}
|
||||
if err := app.Connect(srv.URL, "evan@example.com", "pw"); err != nil {
|
||||
t.Fatalf("Connect 失敗:%v", err)
|
||||
}
|
||||
|
||||
path := defaultLibraryPath()
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("前置條件失敗:預設庫應該已經存在:%v", err)
|
||||
}
|
||||
|
||||
// 使用者從 Finder/檔案總管把整個資料夾刪掉
|
||||
if err := os.RemoveAll(path); err != nil {
|
||||
t.Fatalf("刪除失敗:%v", err)
|
||||
}
|
||||
|
||||
// App 下一次讀狀態(等同 GetState 的自我修復邏輯)應該把殘留引用清掉
|
||||
cfg, err := loadCfg()
|
||||
if err != nil {
|
||||
t.Fatalf("讀不回設定:%v", err)
|
||||
}
|
||||
if !pruneMissingDefaultLibrary(cfg) {
|
||||
t.Fatal("應該偵測到預設庫已被刪除並回報 changed=true")
|
||||
}
|
||||
if err := saveCfg(cfg); err != nil {
|
||||
t.Fatalf("存檔失敗:%v", err)
|
||||
}
|
||||
|
||||
cfg2, _ := loadCfg()
|
||||
for _, f := range cfg2.Accounts[0].WatchFolders {
|
||||
if f == path {
|
||||
t.Fatal("刪掉的預設庫路徑還留在 watch_folders 裡——幽靈資料")
|
||||
}
|
||||
}
|
||||
|
||||
// 再跑一次種子邏輯(模擬使用者重開程式、或又連了一次同一個帳號)——
|
||||
// marker 檔還在,絕對不能把資料夾或引用生回來。
|
||||
seedDefaultLibraryIfFirstEver(cfg2, 0)
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("預設庫刪掉後又長回來了:%v", err)
|
||||
}
|
||||
for _, f := range cfg2.Accounts[0].WatchFolders {
|
||||
if f == path {
|
||||
t.Fatal("種子邏輯把已刪除的預設庫路徑又加回 watch_folders 了")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultLibraryNotSeededWhenAccountsAlreadyExist 釘住紅線②:
|
||||
// 「只要有掛用戶自己的資料夾,就不再出現」——這台機器如果已經連過帳號,
|
||||
// 之後不管再連幾個新帳號,都不應該冒出這個預設庫。
|
||||
func TestDefaultLibraryNotSeededWhenAccountsAlreadyExist(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
|
||||
// 模擬「這台機器已經有一個帳號,且已經掛了使用者自己的資料夾」
|
||||
preexisting := &directConfig{
|
||||
Accounts: []accountCfg{{
|
||||
InstanceName: "既有帳號",
|
||||
CypherURL: "https://instance-existing.example.workers.dev",
|
||||
Namespace: "existing",
|
||||
APIKey: "existing",
|
||||
WatchFolders: []string{filepath.Join(home, "我的筆記")},
|
||||
}},
|
||||
Extractor: "workers-ai",
|
||||
Manifest: filepath.Join(home, ".arcrun-rag", "manifest.json"),
|
||||
}
|
||||
if err := saveCfg(preexisting); err != nil {
|
||||
t.Fatalf("前置存檔失敗:%v", err)
|
||||
}
|
||||
|
||||
srv := fakeDaemonConfigServer(t, "https://instance-c.example.workers.dev")
|
||||
app := &App{}
|
||||
if err := app.Connect(srv.URL, "second@example.com", "pw"); err != nil {
|
||||
t.Fatalf("Connect 失敗:%v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(defaultLibraryPath()); !os.IsNotExist(err) {
|
||||
t.Fatalf("已有帳號的機器不該冒出預設庫,但資料夾存在:err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(defaultLibraryMarkerPath()); !os.IsNotExist(err) {
|
||||
t.Fatal("已有帳號的機器不該寫出 marker 檔")
|
||||
}
|
||||
|
||||
cfg, _ := loadCfg()
|
||||
if len(cfg.Accounts) != 2 {
|
||||
t.Fatalf("應該有兩個帳號(既有 + 新連的),實際 %d", len(cfg.Accounts))
|
||||
}
|
||||
for _, acc := range cfg.Accounts {
|
||||
for _, f := range acc.WatchFolders {
|
||||
if f == defaultLibraryPath() {
|
||||
t.Fatal("預設庫路徑不該出現在任何帳號的 watch_folders 裡")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user