edit post标题slug(URL 路径,中文标题请手动输入)摘要(可选)状态草稿已发布已归档正文(Markdown)## 1. 它为什么不是 MVC 一句话定位: > **PSP 是一个“文件同步内核 + 两个入口”:CLI 和 HTTP 服务只是外壳,真正的业务在 `src/mem` 和 `src/sync` 里。** 它按 **能力/领域** 切目录,而不是按 `controller/service/repository` 切层: ``` ┌──────────── 入口/适配器 ────────────┐ 终端命令 ──▶ src/cli.ts ─┐ ├──▶ src/mem/* 共享目录、清单、变更、导出 浏览器 ──▶ web/dist ──▶ src/server/index.ts ─┤ ├──▶ src/sync/* 好友镜像拉取 好友节点 ──HTTP──▶ /api/peer/* ───────────────┘ │ ▼ ~/.psp/ (文件系统就是数据库) ``` 这其实更接近 **六边形架构(端口-适配器)**: - `cli.ts` 和 `server/index.ts` 是两个 **Adapter** - `mem/`、`sync/` 是 **Core** - 数据出口是 `~/.psp` 下的 JSON 和目录 所以不要找 `@RestController`、`@Service`、`@Repository`,这里对应的是: | Java / Spring 世界 | 这个项目 | |---|---| | `main()` + 命令分发 | `src/cli.ts:207` 的 `switch (cmd)` | | `@RestController` / Router | `src/server/index.ts:18` 的 `createApp()`,路由是 lambda | | `@Service` | `src/mem/*.ts`、`src/sync/friends.ts` 导出的函数 | | `@Repository` / JPA | 直接 `fs.readFileSync/writeFileSync` + JSON 文件 | | `application.yml` | `~/.psp/config.json` + `src/config.ts` | | `@Scheduled` | `setInterval`(`watcher.ts:8`、`cli.ts` 的 60s 同步) | | Spring DI 容器 | **ESM import 本身**:模块只会执行一次,导出的就是单例 | | DTO / VO | `interface`,**编译后类型全部擦除**,运行时没有校验 | | Maven / Gradle | `package.json` + `package-lock.json` + `node_modules` | | 内嵌 Tomcat | `@hono/node-server` 的 `serve()` | | Thymeleaf / JSP | React SPA(`web/`),由后端静态托管 | | `CompletableFuture` | `Promise` / `async-await` | | `ApplicationContext` 启动 | `initRegistry()` → `createApp()` → `serve()` | **关键认知:这里没有数据库、没有 ORM、没有事务、没有 DI 容器。** “数据”就是 `~/.psp` 下的文件;“依赖注入”就是 `import`;“事务”要靠代码自己保证(很多地方其实是直接覆盖写)。 --- ## 2. 业务内核:先记住 5 个概念 | 概念 | 代码 | 含义 | |---|---|---| | `SharedDir` | `config.ts` | 我要共享出去的目录:`{id, path, name}`,`id` 是路径 sha256 前 8 位 | | `Manifest` | `manifest.ts:10` | 目录的“文件清单”:每个文件的 `path/sha256/size/mtime` + 整个目录的指纹 `hash` | | `FriendProfile` | `friends.ts:10` | 好友名片:地址、配对码、对方目录摘要、`selected`、`paused` | | Mirror(镜像) | `friends.ts:90` | 好友目录在本地的只读副本:`~/.psp/friends/<昵称>/<dirId>/` | | Export(导出) | `exporter.ts:58` | 合并后的知识库:`~/.psp/export/<昵称>/<目录名>/...` + `INDEX.md` | **整个项目的业务逻辑可以压缩成一句话:** > 把自己的共享目录做成带哈希的文件清单;好友之间通过 HTTP 比对清单,只拉取变化的文件,形成本地镜像;最后把“选中”的好友镜像合并到一个导出目录,再软链接给其它 Agent 使用。 注意:**同步是“拉”不是“推”**。每个节点每 60 秒主动去拉自己的好友,没有中心服务器,也没有推送。 --- ## 3. 四条主流程(按执行顺序读) ### 流程 A:启动 `psp start` 代码:`src/cli.ts:44` ``` 读参数 --port/--host → 检查 ~/.psp/config.json 是否存在 → ensureDirs() → initRegistry(cfg) # 为所有共享目录建 manifest(mem/registry.ts:12) → startWatcher(3000) # 每 3 秒检测本地共享目录变化(watcher.ts:8) → createApp(cfg) # 组装 Hono 路由(server/index.ts:18) → serve(127.0.0.1 或 0.0.0.0:7788) → 立即 syncAndRebuild(),之后每 60 秒一次(拉好友 + 重建导出) ``` ### 流程 B:共享目录 + 变更感知 代码:`src/mem/sources.ts`、`src/mem/registry.ts`、`src/mem/watcher.ts` ``` psp share <路径> 或 POST /api/sources/share → shareDir(): 校验路径存在、去重、生成 id、写入 config.json(sources.ts:39) → registry.refreshChanged(): 重新 buildManifest → 每 3 秒 watcher 再刷新一次,hash 变了就打印日志 → 好友下次来拉时,才能看到新内容 ``` `buildManifest` 是核心算法(`manifest.ts:35`): - 递归遍历目录,忽略 `.git`、`node_modules` 等 - 每个文件算 sha256,但 **mtime+size 没变就复用上次的 hash**,避免重复读文件 - 把 `path:sha256` 排序后再 hash,得到目录指纹 ### 流程 C:加好友 + 同步(最核心) 代码:`src/sync/friends.ts:90-186` ``` 我(A) 好友(B) addFriend() → GET /api/peer/hello ─────────────────▶ 校验 x-psp-code 配对码 ◀──────────────────────────── dirs:[{dirId,name,fileCount,hash}](不含绝对路径) → 保存 profile.json syncFriend() → 再次 hello,拿到对方目录列表 → 对每个目录 syncOneDir(): 本地 .psp-manifest.json.hash == 对方 hash ? 是 → 跳过 否 → GET /api/peer/manifest/:dirId 拿完整清单 → 只拉 sha256 不同的文件 → GET /api/peer/file?dirId=&path= → 写入 ~/.psp/friends/<昵称>/<dirId>/ → 删除对方已不存在的文件 → 写 .psp-manifest.json → 对方取消共享的目录,本地镜像一并删除 → 更新 profile.json → rebuildExport() ``` 对应服务端:`src/server/index.ts:160-199`。这里有两个安全点值得注意: - `peer.use('*')` 校验 `x-psp-code`(`index.ts:160`) - `/file` 只允许 manifest 里存在的相对路径(`fileInManifest`,`manifest.ts:86`) ### 流程 D:导出 + 挂载 代码:`src/mem/exporter.ts:58` ``` selected 的好友镜像 → rm -rf ~/.psp/export(全量重建) → 按 好友/目录/文件 复制 → 对 md/txt 提取首个标题或首行,生成 INDEX.md → linkExport(): 在目标目录创建软链接 psp-kb -> ~/.psp/export(exporter.ts:116) - 已有自己的链接:幂等 - 同名但不是自己的:拒绝覆盖 → 其它 Agent 读 .claude/memory/psp-kb 就等于读合并知识库 ``` --- ## 4. 代码地图(读的时候按这个顺序打开) | 文件 | 读它的目的 | |---|---| | `package.json`、`tsconfig.json`、`bin/psp.js` | 搞清怎么构建、入口在哪 | | `src/cli.ts` | 所有命令和启动逻辑(先看 `:207` 的 switch) | | `src/config.ts` | 数据模型和持久化位置(`:25` 的 `~/.psp`) | | `src/mem/manifest.ts` | **最核心的算法**,不到 90 行 | | `src/mem/registry.ts` | 进程内的 manifest 缓存 | | `src/mem/sources.ts` | 目录探测/共享 | | `src/mem/watcher.ts` | 3 秒轮询 | | `src/sync/friends.ts` | HTTP 拉取 + 镜像(核心业务) | | `src/server/index.ts` | 本地 REST + peer API + 静态托管 | | `src/mem/exporter.ts` | 合并导出 + 软链接 | | `web/src/api.ts` | **前后端契约**,先看这个 | | `web/src/App.tsx` | 4 个页签的入口 | | `web/src/pages/*.tsx` | 每个页面的交互 | > ⚠️ **不要先读 `dist/`**。那是 `tsc` 生成的 JS,和 `src/` 一一对应。你如果打开的是 `dist`,会觉得更模糊。永远从 `src/` 读。 --- ## 5. 建议的阅读顺序(4 轮,每轮带问题) ### 第 1 轮:入口与命令(约 20 分钟) 读 `package.json`、`bin/psp.js`、`cli.ts:44-84`、`cli.ts:207-260`。 要能回答: - 有哪几种入口?(`npm run dev`、`npm start`、全局 `psp`) - `start` 做了哪 6 件事? - 哪些命令改 `config.json`,哪些改好友 `profile.json`? ### 第 2 轮:核心数据结构(约 40 分钟) 读 `config.ts`、`manifest.ts` 全文、`registry.ts`、`sources.ts`、`watcher.ts`。 要能回答: - 目录指纹 `hash` 是怎么算出来的? - 为什么 mtime+size 不变就能跳过读文件? - `redis` 在这里对应什么?(没有,是进程内 `Map`) - 共享目录被删除/取消共享后,manifest 会怎样? ### 第 3 轮:网络与同步(约 40 分钟) 读 `server/index.ts:24-199`、`friends.ts:59-186`。 要能回答: - 本地 API 和 `/api/peer/*` 的区别是什么? - 同步是 push 还是 pull?谁触发? - “只拉差异”具体比对的是什么? - 配对码起什么作用?没有它会怎样? ### 第 4 轮:导出与前端(约 30 分钟) 读 `exporter.ts:58-135`、`web/src/api.ts:80-170`、`App.tsx`、`pages/Sources.tsx`。 要能回答: - 导出目录结构是什么样? - 软链接是怎么安全创建的?什么情况下拒绝? - 前端一个按钮点击后,请求经过哪些文件? **最后做一次“业务追踪”**:假设我要让好友 B 的文件出现在 `~/.claude/memory/psp-kb`,从点击“添加好友”到文件可读,代码依次经过哪些函数?把这条链路在纸上画出来,你就真正读懂了。 --- ## 6. Java 开发者最容易卡住的 9 个点 1. **`src` 和 `dist` 是两份代码**:`dist` 是编译产物,读 `src`。 2. **TS 里 import 写 `.js` 后缀**(如 `import './config.js'`),但源文件是 `.ts`。这是 NodeNext 模块解析的要求,不是写错了。 3. **`interface` 运行时不存在**:没有反射、没有 Bean Validation。字段校验都是手写的(如 `server/index.ts:58`)。 4. **没有 DI 容器**:模块级 `let cfg` / `Map` 就是全局单例(`registry.ts`)。**初始化顺序很重要**:必须先 `initRegistry(cfg)`,peer API 才有数据。 5. **没有事务**:`rebuildExport()` 先 `rm -rf` 再复制,中间存在导出目录为空的窗口。 6. **异步错误经常被吞掉**:`syncAllFriends().catch(() => {})`、`syncFriend` 里只记录 `lastError` 不抛出到 UI。看代码时要注意“失败后谁负责”。 7. **两个 npm 工程**:根目录是后端/CLI,`web/` 是独立前端,各有 `package.json` 和 `node_modules`。 8. **没有测试**(这个仓库确实没有任何 `*.test.*`)。行为以代码和运行时为准,读的时候最好配合断点/日志。 9. **CLI 和 server 是两个进程**:命令行 `psp share` 改的是磁盘上的 `config.json`;正在运行的 server 持有的是启动时加载进内存的 `cfg`,不会自动重读。所以服务运行中改共享目录,用网页操作,或者重启。这个点能解释很多“为什么我改了没生效”。 --- ## 7. 把项目跑起来 + 用断点读 ```bash # 首次:初始化(会写 ~/.psp/config.json) npx tsx src/cli.ts init # 开发模式启动(tsx 直接跑 TS,不需要 build) npm run dev # 另开终端观察状态 curl -s localhost:7788/api/status find ~/.psp -maxdepth 4 -type f cat ~/.psp/config.json cat ~/.psp/friends/*/profile.json # 验证 peer 鉴权 curl -i localhost:7788/api/peer/hello # 401 curl -s -H "x-psp-code: $(python3 -c 'import json;print(json.load(open("'"$HOME"'/.psp/config.json"))["pairCode"])')" \ localhost:7788/api/peer/hello ``` VS Code 调试配置(用 Java Debugger 的直觉来用): ```json { "type": "node", "request": "launch", "name": "PSP debug", "runtimeExecutable": "node", "runtimeArgs": ["--import", "tsx"], "program": "${workspaceFolder}/src/cli.ts", "args": ["start"], "cwd": "${workspaceFolder}", "console": "integratedTerminal", "skipFiles": ["<node_internals>/**"] } ``` 建议断点位置: - `src/cli.ts:70`(syncAndRebuild) - `src/sync/friends.ts:111`(写镜像文件) - `src/mem/exporter.ts:72`(复制到导出目录) - `src/server/index.ts:182`(peer 文件下载) --- ## 8. 开发逻辑:改一个功能要动哪些文件 | 需求 | 改动点 | |---|---| | 新增一个 REST API | `server/index.ts` 加路由 → `web/src/api.ts` 加方法 → 对应页面调用 | | 新增一个 CLI 命令 | `cli.ts` 写 `cmdX()` → switch 加 case → help 文案 | | 新增配置字段 | `config.ts` 的 interface + `defaultConfig()`(旧配置靠 `{...def, ...raw}` 兜底,没有迁移框架) | | 改同步策略 | `friends.ts:90 syncOneDir()` | | 改导出目录结构 | `exporter.ts:58 rebuildExport()` | | 新增页面 | `web/src/pages/X.tsx` → `App.tsx` 加 tab | 这套代码的“开发逻辑”就是:**先判断这个需求属于哪个 core 模块,再决定从 CLI 还是 HTTP 入口暴露,最后决定要不要同步改前端契约。** --- ## 9. 读代码时顺手能发现的问题(证明你读进去了) 1. `src/server/index.ts:171` 返回 `version: '0.2.0'`,但 `package.json` 是 `0.3.0`。 2. `src/config.ts:28` 的 `DB_PATH` 定义了但全项目没用到(旧设计残留)。 3. `unshare` 只从 `cfg.sharedDirs` 删除,`registry.manifests` 不清理;而 `/api/peer/manifest/:dirId`(`index.ts:177`)只查 `getManifest`,所以重启前旧目录的清单仍可被好友读到(文件下载会被 `getSharedDir` 挡住)。 4. `friends.ts:111` 直接把对方 manifest 里的 `f.path` 拼到本地路径,没有像 `fileInManifest` 那样的 `..` 校验。对“完全可信的好友”没问题,但这是信任边界上的一个隐患。 5. 整个仓库没有测试,也没有后端 lint;只有前端有 `oxlint`。保存删除文章← back to blog