feat(vault): initialize fresh instances with public example notes

This commit is contained in:
eezd committed 2026-10-27 13:41:10 +08:00
1 parent 9ee7402c8e
commit 10fd6d8efb
24 files changed
+438 -14

No files matched your search

+5 -1
View File
@@ -4,4 +4,8 @@ web/dist
CONTEXT.md CONTEXT.md
docs docs
.dev-data .dev-data
deploy/data deploy/data/*
!deploy/data/vault
deploy/data/vault/**/.rawmark
deploy/data/**/*.db*
deploy/data/**/*.log
+9 -1
View File
@@ -17,7 +17,15 @@ web/dist/
# Local development data # Local development data
.dev-data/ .dev-data/
deploy/data/
# Public examples are tracked; instance state must remain outside the template.
deploy/data/*
!deploy/data/vault/
deploy/data/vault/**/.rawmark/
deploy/data/**/*.db
deploy/data/**/*.db-wal
deploy/data/**/*.db-shm
deploy/data/**/*.log
# Repository-maintained tool rules (other local skills stay untracked) # Repository-maintained tool rules (other local skills stay untracked)
.agents/skills/* .agents/skills/*
+2
View File
@@ -16,6 +16,8 @@ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY cmd ./cmd COPY cmd ./cmd
COPY internal ./internal COPY internal ./internal
COPY deploy/embed.go ./deploy/
COPY deploy/data/vault ./deploy/data/vault
COPY web/embed.go web/font_storage.go ./web/ COPY web/embed.go web/font_storage.go ./web/
COPY --from=web /src/web/dist ./web/dist COPY --from=web /src/web/dist ./web/dist
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/rawmark ./cmd/rawmark && mkdir -p /out/data RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/rawmark ./cmd/rawmark && mkdir -p /out/data
+1 -1
View File
@@ -48,7 +48,7 @@ docker run -d --name rawmark --restart unless-stopped \
rawmark rawmark
``` ```
Open **http://localhost:21000**, set your password on first use, and create your first note. Data lives in the Docker volume `rawmark-data` and persists when you recreate the container with the same volume. Open **http://localhost:21000** and set your password on first use. A new, empty instance starts with seven English example notes; edit or delete them and begin writing. Data lives in the Docker volume `rawmark-data` and persists when you recreate the container with the same volume.
These commands listen on localhost only. For a remote server, complete the initial setup before configuring HTTPS access. See the [deployment guide](docs/deployment.md) for Docker Compose, existing note folders, backups, and upgrades. These commands listen on localhost only. For a remote server, complete the initial setup before configuring HTTPS access. See the [deployment guide](docs/deployment.md) for Docker Compose, existing note folders, backups, and upgrades.
+1 -1
View File
@@ -48,7 +48,7 @@ docker run -d --name rawmark --restart unless-stopped \
rawmark rawmark
``` ```
打开 **http://localhost:21000**,首次访问设置登录密码,即可创建第一篇笔记。数据保存在 Docker 卷 `rawmark-data` 中,重建容器不会丢失。 打开 **http://localhost:21000**,首次访问设置登录密码。全新空实例会提供 7 篇英文示例笔记,可以自由编辑或删除,然后开始记录自己的内容。数据保存在 Docker 卷 `rawmark-data` 中,重建容器不会丢失。
以上命令仅监听本机。部署到服务器时,请先完成初始化,再配置 HTTPS 访问;Docker Compose、已有笔记目录接入、备份与升级见[部署文档](docs/deployment.zh-CN.md)。 以上命令仅监听本机。部署到服务器时,请先完成初始化,再配置 HTTPS 访问;Docker Compose、已有笔记目录接入、备份与升级见[部署文档](docs/deployment.zh-CN.md)。
+34
View File
@@ -0,0 +1,34 @@
package main
import (
"fmt"
"io/fs"
"rawmark/deploy"
"rawmark/internal/auth"
"rawmark/internal/store"
"rawmark/internal/vault"
)
// Keep bootstrap state out of user preferences so resetting settings cannot restore deleted examples.
const examplesSetting = "bootstrap.examples"
func initializeExamples(v *vault.Vault, st *store.Store) error {
_, done, err := st.Setting(examplesSetting)
if err != nil || done {
return err
}
setUp, err := auth.New(st).IsSetUp()
if err != nil {
return err
}
if !setUp {
examples, err := fs.Sub(deploy.Examples, "data/vault")
if err != nil {
return err
}
if err := v.SeedExamples(examples); err != nil {
return fmt.Errorf("初始化示例笔记失败: %w", err)
}
}
return st.SetSetting(examplesSetting, "1")
}
+109
View File
@@ -0,0 +1,109 @@
package main
import (
"io/fs"
"os"
"path/filepath"
"rawmark/deploy"
"rawmark/internal/auth"
"rawmark/internal/store"
"rawmark/internal/vault"
"testing"
)
func TestInitializeExamplesOnlyOnce(t *testing.T) {
data := t.TempDir()
v, err := vault.Open(filepath.Join(data, "vault"))
if err != nil {
t.Fatal(err)
}
st, err := store.Open(filepath.Join(data, "rawmark.db"))
if err != nil {
t.Fatal(err)
}
if err := initializeExamples(v, st); err != nil {
t.Fatal(err)
}
if ready, err := auth.New(st).IsSetUp(); err != nil || ready {
t.Fatalf("examples must not initialize credentials: %v, %v", ready, err)
}
count := 0
if err := fs.WalkDir(deploy.Examples, "data/vault", func(name string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() {
return nil
}
if filepath.Ext(name) != ".md" {
t.Fatalf("unexpected template file: %s", name)
}
count++
rel, err := filepath.Rel("data/vault", name)
if err != nil {
return err
}
if _, err := os.Stat(filepath.Join(v.Root(), rel)); err != nil {
return err
}
return nil
}); err != nil {
t.Fatal(err)
}
if count != 7 {
t.Fatalf("expected seven public sample notes, got %d", count)
}
// Deletion before password setup, settings reset, and process restart must not restore examples.
entries, err := os.ReadDir(v.Root())
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
if entry.Name() != ".rawmark" {
if err := os.RemoveAll(filepath.Join(v.Root(), entry.Name())); err != nil {
t.Fatal(err)
}
}
}
if err := st.ResetPreferences(); err != nil {
t.Fatal(err)
}
if err := st.Close(); err != nil {
t.Fatal(err)
}
st, err = store.Open(filepath.Join(data, "rawmark.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := initializeExamples(v, st); err != nil {
t.Fatal(err)
}
entries, err = os.ReadDir(v.Root())
if err != nil || len(entries) != 1 || entries[0].Name() != ".rawmark" {
t.Fatalf("deleted examples were restored: %v, %v", entries, err)
}
}
func TestInitializeExamplesSkipsInitializedEmptyInstance(t *testing.T) {
data := t.TempDir()
v, err := vault.Open(filepath.Join(data, "vault"))
if err != nil {
t.Fatal(err)
}
st, err := store.Open(filepath.Join(data, "rawmark.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := auth.New(st).Setup("synthetic-test-password"); err != nil {
t.Fatal(err)
}
if err := initializeExamples(v, st); err != nil {
t.Fatal(err)
}
entries, err := os.ReadDir(v.Root())
if err != nil || len(entries) != 1 || entries[0].Name() != ".rawmark" {
t.Fatalf("examples were added to an existing account: %v, %v", entries, err)
}
}
+3
View File
@@ -129,6 +129,9 @@ func serve(parent context.Context, addr, dataDir, vaultDir, version string, font
return false, err return false, err
} }
defer st.Close() defer st.Close()
if err := initializeExamples(v, st); err != nil {
return false, err
}
idx, err := search.Open(filepath.Join(dataDir, "index.db"), v) idx, err := search.Open(filepath.Join(dataDir, "index.db"), v)
if err != nil { if err != nil {
return false, err return false, err
@@ -0,0 +1,13 @@
# Markdown Guide
Start with headings, lists, and links.
## Headings
Use `#` to organize your notes.
## Lists
- An idea
- A plan
- A next step
@@ -0,0 +1,25 @@
# A space of your own.
Capture ideas. Connect thoughts. Keep writing with **RawMark**, your personal Markdown workspace.
> Your notes. Your files. Your space.
- [x] Create a home for your knowledge
- [ ] Write down your next idea
## Familiar Markdown
Keep **bold** ideas, *small details*, and ==highlights== in one place.
| What to capture | Where it belongs | Keep it moving |
| --- | --- | --- |
| Project plans | Projects | Take the next step |
| Reading notes | Knowledge Base | Connect new ideas |
## Keep ideas growing
```typescript
const notes = await collectIdeas()
const garden = notes.map(idea => idea.grow())
console.log('Keep writing, keep growing.')
```
+5
View File
@@ -0,0 +1,5 @@
# Things to Try
- Document a journey
- Add illustrations to an old note
- Revisit the knowledge base each week
@@ -0,0 +1,3 @@
# Learning Notes
Explain what you learn in your own words.
@@ -0,0 +1,5 @@
# Reading List
- Design and everyday life
- Software and ideas
- The joy of writing
@@ -0,0 +1,7 @@
# Product Design
Make every note feel simple and natural.
## Principles
Less distraction. More focus.
@@ -0,0 +1,7 @@
# Weekly Plan
## In progress
- [x] Organize project notes
- [ ] Draft the first proposal
- [ ] Make time for focused writing
+9
View File
@@ -0,0 +1,9 @@
// Package deploy embeds the public example vault used by new instances.
package deploy
import "embed"
// Examples is the single source of the example files shipped in every binary.
//
//go:embed data/vault
var Examples embed.FS
+13 -3
View File
@@ -28,16 +28,26 @@ ssh -L 21000:127.0.0.1:21000 user@your-server
The container defaults to UID/GID `65532:65532`. A new Docker named volume inherits permissions from the image's data directory. The container defaults to UID/GID `65532:65532`. A new Docker named volume inherits permissions from the image's data directory.
A fresh instance with an empty vault receives seven English example notes from the public template in `deploy/data/vault/`. They are embedded in the binary, so Docker and native deployments behave the same way. You still set your own password; no account credentials or personal settings are shipped. Existing accounts and nonempty vaults are left untouched. Examples are ordinary user files: deleting them, restarting, or resetting preferences does not recreate them. Initialization state is retained in the application database, outside user preferences.
## Docker Compose ## Docker Compose
The repository includes [deploy/docker-compose.yml](../deploy/docker-compose.yml), which persists data in the host's `deploy/data/`. For a first deployment on Linux: The repository includes [deploy/docker-compose.yml](../deploy/docker-compose.yml), which stores runtime data in an independent Docker named volume:
```sh ```sh
mkdir -p deploy/data
sudo chown 65532:65532 deploy/data
docker compose -f deploy/docker-compose.yml up -d --build --wait docker compose -f deploy/docker-compose.yml up -d --build --wait
``` ```
The tracked `deploy/data/` directory is a public example template, not a runtime data directory. Do not mount it as writable application storage. To use a host directory instead, create it with UID/GID `65532:65532` and set an absolute path:
```sh
sudo install -d -o 65532 -g 65532 /your/storage/rawmark/data
RAWMARK_DATA_DIR=/your/storage/rawmark/data \
docker compose -f deploy/docker-compose.yml up -d --build --wait
```
Keep the same `RAWMARK_DATA_DIR` value for every subsequent Compose command, for example in a private file passed with `--env-file`. This Compose variable selects the host mount; it is separate from the application's `RAWMARK_DATA` variable inside the container. Existing deployments that used `deploy/data/` must explicitly select that directory until it has been migrated; omitting the override starts a separate instance in a named volume.
This configuration publishes port **21000** on all network interfaces by default. Complete initialization and configure access boundaries before exposing it publicly. With a reverse proxy, you can change the Compose port binding to `127.0.0.1:${RAWMARK_PORT:-21000}:8080`. For a custom port, use `RAWMARK_PORT=21001 docker compose -f deploy/docker-compose.yml up -d --build --wait`. This configuration publishes port **21000** on all network interfaces by default. Complete initialization and configure access boundaries before exposing it publicly. With a reverse proxy, you can change the Compose port binding to `127.0.0.1:${RAWMARK_PORT:-21000}:8080`. For a custom port, use `RAWMARK_PORT=21001 docker compose -f deploy/docker-compose.yml up -d --build --wait`.
Do not point the quick-start container and a Compose instance at the same database or vault concurrently. Stop the old instance, back up, and verify data locations before switching deployment methods. Do not point the quick-start container and a Compose instance at the same database or vault concurrently. Stop the old instance, back up, and verify data locations before switching deployment methods.
+13 -3
View File
@@ -28,16 +28,26 @@ ssh -L 21000:127.0.0.1:21000 user@your-server
容器默认以 UID/GID `65532:65532` 运行,Docker 新建命名卷会继承镜像数据目录的权限。 容器默认以 UID/GID `65532:65532` 运行,Docker 新建命名卷会继承镜像数据目录的权限。
全新实例的空笔记库会自动生成 `deploy/data/vault/` 公开模板中的 7 篇英文示例笔记。示例嵌入二进制,Docker 与原生部署行为一致。仍需自行设置密码,不附带账号凭据或私人设置;已有账号和非空笔记库不会被添加示例。示例是普通文件,删除、重启或重置偏好设置后都不会重新生成;一次性初始化状态保存在应用数据库中,不属于用户偏好设置。
## Docker Compose ## Docker Compose
仓库提供 [deploy/docker-compose.yml](../deploy/docker-compose.yml),使用宿主机的 `deploy/data/` 持久化。Linux 上首次部署可执行: 仓库提供 [deploy/docker-compose.yml](../deploy/docker-compose.yml),默认使用独立的 Docker 命名卷保存运行数据:
```sh ```sh
mkdir -p deploy/data
sudo chown 65532:65532 deploy/data
docker compose -f deploy/docker-compose.yml up -d --build --wait docker compose -f deploy/docker-compose.yml up -d --build --wait
``` ```
纳入版本控制的 `deploy/data/` 是公开示例模板,不是运行数据目录,不要将其作为可写数据目录挂载。若使用宿主机目录,先创建目录并设置 UID/GID `65532:65532`,再指定绝对路径:
```sh
sudo install -d -o 65532 -g 65532 /your/storage/rawmark/data
RAWMARK_DATA_DIR=/your/storage/rawmark/data \
docker compose -f deploy/docker-compose.yml up -d --build --wait
```
后续每次执行 Compose 命令都必须使用相同的 `RAWMARK_DATA_DIR`,例如保存在通过 `--env-file` 指定的私有文件中。该 Compose 变量选择宿主机挂载位置,与容器内应用使用的 `RAWMARK_DATA` 不同。原先使用 `deploy/data/` 的实例,在迁出前必须显式指定原目录;省略此覆盖值会在命名卷中启动另一个独立实例。
该配置默认发布到所有网卡的 **21000** 端口。对公网开放前完成初始化并设置访问边界;使用反向代理时,可将 Compose 端口绑定改为 `127.0.0.1:${RAWMARK_PORT:-21000}:8080`。自定义端口可使用 `RAWMARK_PORT=21001 docker compose -f deploy/docker-compose.yml up -d --build --wait`。 该配置默认发布到所有网卡的 **21000** 端口。对公网开放前完成初始化并设置访问边界;使用反向代理时,可将 Compose 端口绑定改为 `127.0.0.1:${RAWMARK_PORT:-21000}:8080`。自定义端口可使用 `RAWMARK_PORT=21001 docker compose -f deploy/docker-compose.yml up -d --build --wait`。
不要把快速开始中的容器与 Compose 实例同时指向同一份数据库或笔记库;切换部署方式前先停止旧实例、备份并确认数据位置。 不要把快速开始中的容器与 Compose 实例同时指向同一份数据库或笔记库;切换部署方式前先停止旧实例、备份并确认数据位置。
+1 -1
View File
@@ -53,7 +53,7 @@ web/
router/ Routes and guards router/ Routes and guards
pinia/ Pinia instance and shared auth, permission, settings state pinia/ Pinia instance and shared auth, permission, settings state
types/auto/ Generated import declarations types/auto/ Generated import declarations
deploy/ Container and proxy configuration deploy/ Container/proxy configuration and embedded public example vault
scripts/ Development, resource generation, historical migrations scripts/ Development, resource generation, historical migrations
``` ```
+1 -1
View File
@@ -53,7 +53,7 @@ web/
router/ 路由与守卫 router/ 路由与守卫
pinia/ 实例及共享认证、权限、设置状态 pinia/ 实例及共享认证、权限、设置状态
types/auto/ 自动生成的导入声明 types/auto/ 自动生成的导入声明
deploy/ 容器与代理配置 deploy/ 容器、代理配置及嵌入的公开示例笔记库
scripts/ 开发、资源生成及历史迁移脚本 scripts/ 开发、资源生成及历史迁移脚本
``` ```
+1 -1
View File
@@ -8,7 +8,7 @@ RawMark is a single-user workspace with notes stored on your server. Multiple br
## Start writing ## Start writing
Set a password on first use. Subsequent sign-ins require your password and an image captcha. Right-click the **Files** heading or empty space in the sidebar to search, create notes or folders, import documents, open the tag index, or manage images. You can also click the heading to open the menu on a touchscreen. Set a password on first use. A new, empty instance includes seven English example notes that you can edit or delete; they do not return after deletion or restart. Existing vaults are not populated with examples. Subsequent sign-ins require your password and an image captcha. Right-click the **Files** heading or empty space in the sidebar to search, create notes or folders, import documents, open the tag index, or manage images. You can also click the heading to open the menu on a touchscreen.
The editor opens in live preview mode. Use the icons at the top right to switch to source mode, open the outline, or enter focus mode. Supported syntax includes standard Markdown, tables, task lists, code blocks, math, Mermaid, footnotes, and YAML metadata. The editor opens in live preview mode. Use the icons at the top right to switch to source mode, open the outline, or enter focus mode. Supported syntax includes standard Markdown, tables, task lists, code blocks, math, Mermaid, footnotes, and YAML metadata.
+1 -1
View File
@@ -8,7 +8,7 @@ RawMark 是单用户笔记工作台,笔记保存在服务器上。多个浏览
## 开始写作 ## 开始写作
首次访问设置密码,之后通过密码和图片验证码登录。在侧栏「文件目录」标题或空白处右键,可以搜索、新建笔记、新建目录、导入文档、打开标签索引或管理图片;点击标题同样可以打开菜单,便于触屏使用。 首次访问设置密码。全新空实例带有 7 篇英文示例笔记,可以编辑或删除;删除或重启后不会再次生成,已有笔记库不会添加示例。之后通过密码和图片验证码登录。在侧栏「文件目录」标题或空白处右键,可以搜索、新建笔记、新建目录、导入文档、打开标签索引或管理图片;点击标题同样可以打开菜单,便于触屏使用。
编辑器默认使用即时预览,点击右上角图标切换源码模式、打开大纲或进入专注写作。支持标准 Markdown、表格、任务列表、代码块、数学公式、Mermaid、脚注和 YAML 元数据。 编辑器默认使用即时预览,点击右上角图标切换源码模式、打开大纲或进入专注写作。支持标准 Markdown、表格、任务列表、代码块、数学公式、Mermaid、脚注和 YAML 元数据。
+72
View File
@@ -0,0 +1,72 @@
package vault
import (
"errors"
"io/fs"
"os"
"path/filepath"
)
// SeedExamples populates only a completely unused vault, before the watcher starts.
// Existing folders, hidden files, icons, or recycled notes count as user data.
func (v *Vault) SeedExamples(examples fs.FS) (err error) {
v.mu.Lock()
defer v.mu.Unlock()
entries, err := os.ReadDir(v.root)
if err != nil {
return err
}
for _, entry := range entries {
if entry.Name() != metaDir || !entry.IsDir() {
return nil
}
}
metadata, err := os.ReadDir(filepath.Join(v.root, metaDir))
if err != nil {
return err
}
for _, entry := range metadata {
if entry.Name() != "trash" || !entry.IsDir() {
return nil
}
}
trash, err := os.ReadDir(v.trash)
if err != nil || len(trash) != 0 {
return err
}
stage, err := os.MkdirTemp(v.root, tmpPrefix+"examples-")
if err != nil {
return err
}
cleanup := true
defer func() {
if cleanup {
err = errors.Join(err, os.RemoveAll(stage))
}
}()
if err = os.CopyFS(stage, examples); err != nil {
return err
}
entries, err = os.ReadDir(stage)
if err != nil {
return err
}
var published []string
for _, entry := range entries {
name := entry.Name()
if err = os.Rename(filepath.Join(stage, name), filepath.Join(v.root, name)); err != nil {
for _, previous := range published {
if rollback := os.Rename(filepath.Join(v.root, previous), filepath.Join(stage, previous)); rollback != nil {
// Preserve the staging directory if it contains the only copy of any files.
cleanup = false
err = errors.Join(err, rollback)
}
}
return err
}
published = append(published, name)
}
return nil
}
+98
View File
@@ -0,0 +1,98 @@
package vault
import (
"errors"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
"testing/fstest"
)
func TestSeedExamplesPreservesHierarchyAndAttachments(t *testing.T) {
v := newVault(t)
source := fstest.MapFS{
"Guide/Welcome.md": {Data: []byte("# Welcome\n\n![Example](./assets/image.png)\n")},
"Guide/assets/image.png": {Data: []byte("example-image")},
}
if err := v.SeedExamples(source); err != nil {
t.Fatal(err)
}
for name, original := range source {
data, err := os.ReadFile(filepath.Join(v.Root(), filepath.FromSlash(name)))
if err != nil || string(data) != string(original.Data) {
t.Fatalf("example %s: %q, %v", name, data, err)
}
}
if err := v.SeedExamples(fstest.MapFS{"Guide/Welcome.md": {Data: []byte("replacement")}}); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(v.Root(), "Guide/Welcome.md"))
if err != nil || string(data) != string(source["Guide/Welcome.md"].Data) {
t.Fatalf("existing content changed: %q, %v", data, err)
}
}
func TestSeedExamplesSkipsAnyExistingData(t *testing.T) {
for _, name := range []string{"Personal.md", "empty-folder/", ".hidden", ".rawmark/tree-icons.json", ".rawmark/trash/deleted/note.md"} {
t.Run(name, func(t *testing.T) {
v := newVault(t)
dest := filepath.Join(v.Root(), filepath.FromSlash(name))
if strings.HasSuffix(name, "/") {
if err := os.MkdirAll(dest, 0755); err != nil {
t.Fatal(err)
}
} else {
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(dest, []byte("private"), 0600); err != nil {
t.Fatal(err)
}
}
if err := v.SeedExamples(fstest.MapFS{"Example.md": {Data: []byte("public")}}); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(v.Root(), "Example.md")); !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("examples were added to a used vault: %v", err)
}
if !strings.HasSuffix(name, "/") {
data, err := os.ReadFile(dest)
if err != nil || string(data) != "private" {
t.Fatalf("existing content changed: %q, %v", data, err)
}
}
})
}
}
type unreadableExampleFS struct{ fs.FS }
func (f unreadableExampleFS) Open(name string) (fs.File, error) {
if name == "Guide/Note.md" {
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrPermission}
}
return f.FS.Open(name)
}
func TestSeedExamplesFailureDoesNotPublishPartialVault(t *testing.T) {
v := newVault(t)
source := unreadableExampleFS{fstest.MapFS{
"First.md": {Data: []byte("staged first")},
"Guide/Note.md": {Data: []byte("unreadable")},
}}
if err := v.SeedExamples(source); !errors.Is(err, fs.ErrPermission) {
t.Fatalf("original failure was lost: %v", err)
}
entries, err := os.ReadDir(v.Root())
if err != nil || len(entries) != 1 || entries[0].Name() != metaDir {
t.Fatalf("partial examples or temporary files remain: %v, %v", entries, err)
}
if err := v.SeedExamples(fstest.MapFS{"Welcome.md": {Data: []byte("retry")}}); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(v.Root(), "Welcome.md")); err != nil {
t.Fatalf("retry failed: %v", err)
}
}