Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,14 @@ bun run scripts/prepare-npm-packages.ts # 从编译产物生成 npm 可发布
| 构建 | Vite 6 |
| 进程管理 | Bun.spawn(detached)+ 状态文件(`~/.paimon/hub.json` / `edge.json`) |

**关键依赖**: highlight.js, react-markdown, rehype-highlight, remark-gfm, remark-frontmatter, js-yaml, lucide-react, i18next, react-i18next, zustand, rotating-file-stream
**关键依赖**: highlight.js, react-markdown, rehype-highlight, remark-gfm, remark-frontmatter, js-yaml, lucide-react, i18next, react-i18next, zustand, rotating-file-stream, global-directory, package-manager-detector

## 项目结构(仅非标准部分)

```
src/
├── protocol/types.ts # 所有消息类型 + 常量(含 Edge 协议)
├── cli/ # paimon CLI 入口(hub / edge / attach 子命令)
├── cli/ # paimon CLI 入口(hub / edge / attach / update / version 子命令)
├── hub/ # Hub 服务端(auth / edge-registry / router / pending / logger)
├── edge/ # Edge 服务端(registry / router / upstream / spawner / browser / log-cleanup)
├── utils/ # 后端共享工具(env 环境常量、logger 日志工厂、timezone 时区初始化)
Expand Down Expand Up @@ -79,7 +79,8 @@ bin/
- **Access Token 认证** — Hub 启动时生成或接收 access token(优先级:`PAIMON_ACCESS_TOKEN` 环境变量 > `--token` 参数 > 自动生成),写入 `hub.json`。Edge/Browser/HTTP API 连接 Hub 时必须携带 token(WS 通过 `?token=xxx`,HTTP 通过 `Authorization: Bearer xxx`)。`/api/health` 不需认证。`PAIMON_AUTH_DISABLED=1` 可关闭认证(仅开发调试)
- **Token 生命周期** — token 存储于 `hub.json`,随 `paimon hub stop` 删除而失效。`paimon hub restart` 默认继承旧 token(显示来源为 `inherited`)。Pi Extension → Edge 不需认证(Edge 仅 bind loopback,天然同机信任)
- **Edge token 来源** — 优先级:`PAIMON_ACCESS_TOKEN` 环境变量 > `--token` 参数 > 同机 hub.json fallback
- **CLI 独立于 npm scripts** — `paimon hub start/stop/restart/status`、`paimon edge start/stop/restart/status` 和 `paimon attach` 是独立的 CLI 工具,入口在 `src/cli/`,不走 `package.json` scripts
- **CLI 独立于 npm scripts** — `paimon hub start/stop/restart/status`、`paimon edge start/stop/restart/status`、`paimon attach`、`paimon update` 和 `paimon version` 是独立的 CLI 工具,入口在 `src/cli/`,不走 `package.json` scripts
- **`paimon update` 自更新** — 按 `isCompiled` 分两种模式:源码模式(`bun link`)执行 `git pull` + `bun install` + `bun run build`,更新前校验工作区干净且能 fast-forward;全局安装模式从 `realpath(process.execPath)` 反查所在 `node_modules` 识别包管理器,并**显式传递安装 prefix**(npm/yarn 用 `--prefix`,pnpm 用 `PNPM_HOME`,bun 用 `BUN_INSTALL`),避免 PATH 上的包管理器与当初安装的 prefix 不一致(nvm 切版本、brew npm 等场景)而装到别处。关键细节:① pnpm 的实包位于与 `node_modules` **同级**的 `.pnpm` 虚拟目录(`<PNPM_HOME>/global/<N>/.pnpm/...`),无法靠 `node_modules` 反查,改为按全局根目录 `<PNPM_HOME>/global` 判定且不依赖目录版本号;② `global-directory` 的 `npm.prefix` 兜底逻辑依赖 node 的 execPath,编译模式下结果错误,**禁止使用**,该库仅用于 yarn/pnpm 目录识别;③ 更新命令 argv 由 `package-manager-detector` 的 `resolveCommand(agent, "global", ...)` 生成;④ 识别不出安装来源时明确报错并给出手动更新命令,不猜测包管理器;⑤ 安装失败时探测 prefix 可写性,不可写则提示改用 sudo。`--check` 只检查不安装。检测与命令构造为纯函数(`update/detect.ts`),有单元测试覆盖四种包管理器的真实路径形态
- **attach = 迁移而非双向接管** — pi 不支持同一 session 文件被多进程同时写,所以 `paimon attach` 的语义是:先调 `POST /api/instance/:id/shutdown` 关闭目标实例 → 轮询其从列表消失(3s 超时)→ 本地 `pi --session <sessionId>`(cwd=实例 cwd,stdio inherit)。过滤条件为 hostname + cwd 双重匹配(均 realpath 规范化)。被 attach 的原实例会退出由用户负责
- **CLI 全局安装走 `bun link`** — `package.json` 的 `bin` 指向 `src/cli/index.ts`(非编译产物),bun 直接执行带 shebang 的 ts 源码。`bun link` 在 `~/.bun/bin/` 建软链接回 clone 目录,`bun unlink` 解除。CLI 入口统一为 `src/cli/index.ts`,通过 `PAIMON_ROLE` 环境变量区分角色(见 Daemon 进程模型)
- **npm 分发模式** — 主包 `@tyanxie/paimon`(含 `bin/paimon.cjs` 启动器 + extension 源码)+ 4 个平台包 `@tyanxie/paimon-{darwin-arm64,darwin-x64,linux-arm64,linux-x64}`(含编译二进制 + web 资产)。主包通过 `optionalDependencies` 引用平台包,npm install 时只下载匹配当前系统的那一个
Expand Down
8 changes: 8 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,12 @@
"dependencies": {
"@commander-js/extra-typings": "^15.0.0",
"commander": "^15.0.0",
"global-directory": "^5.0.0",
"highlight.js": "^11.11.1",
"i18next": "^26.3.1",
"js-yaml": "^4.1.1",
"lucide-react": "^1.14.0",
"package-manager-detector": "^1.8.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-i18next": "^17.0.8",
Expand Down
153 changes: 153 additions & 0 deletions src/cli/commands/update/detect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { describe, expect, test } from "bun:test";
import {
detectGlobalInstall,
buildUpdateCommand,
type KnownGlobalDirs,
} from "./detect";

const HOME = "/home/u";

const dirs: KnownGlobalDirs = {
pnpmGlobalRoot: `${HOME}/.local/share/pnpm/global`,
pnpmPackages: `${HOME}/.local/share/pnpm/global/5/node_modules`,
pnpmPrefix: `${HOME}/.local/share/pnpm`,
yarnPackages: `${HOME}/.config/yarn/global/node_modules`,
yarnPrefix: "/usr/local",
bunPrefix: `${HOME}/.bun`,
};

/** 平台包内二进制的相对路径 */
const BIN = "@tyanxie/paimon-linux-x64/bin/paimon";

describe("detectGlobalInstall", () => {
test("npm via nvm", () => {
const prefix = `${HOME}/.nvm/versions/node/v23.8.0`;
expect(
detectGlobalInstall(`${prefix}/lib/node_modules/${BIN}`, dirs),
).toEqual({
agent: "npm",
prefix,
packagesDir: `${prefix}/lib/node_modules`,
});
});

test("npm via homebrew", () => {
expect(
detectGlobalInstall(`/opt/homebrew/lib/node_modules/${BIN}`, dirs),
).toEqual({
agent: "npm",
prefix: "/opt/homebrew",
packagesDir: "/opt/homebrew/lib/node_modules",
});
});

test("bun global", () => {
expect(
detectGlobalInstall(
`${HOME}/.bun/install/global/node_modules/${BIN}`,
dirs,
),
).toEqual({
agent: "bun",
prefix: `${HOME}/.bun`,
packagesDir: `${HOME}/.bun/install/global/node_modules`,
});
});

test("bun global with custom BUN_INSTALL", () => {
const result = detectGlobalInstall(
`/opt/bun/install/global/node_modules/${BIN}`,
dirs,
);
expect(result?.agent).toBe("bun");
expect(result?.prefix).toBe("/opt/bun");
});

// pnpm 的实包放在与 node_modules 同级的 .pnpm 虚拟目录下(实测 pnpm 10 布局),
// 从二进制向上找到的 node_modules 并非全局安装目录,只能靠全局根目录判定
test("pnpm global via .pnpm virtual store", () => {
const path = `${dirs.pnpmGlobalRoot}/5/.pnpm/@tyanxie+paimon-linux-x64@1.4.0/node_modules/${BIN}`;
expect(detectGlobalInstall(path, dirs)).toEqual({
agent: "pnpm",
prefix: dirs.pnpmPrefix,
packagesDir: dirs.pnpmPackages,
});
});

// 目录版本号未来可能从 5 变化,判定不应依赖它
test("pnpm global is version-number agnostic", () => {
const path = `${dirs.pnpmGlobalRoot}/6/.pnpm/@tyanxie+paimon-linux-x64@2.0.0/node_modules/${BIN}`;
expect(detectGlobalInstall(path, dirs)?.agent).toBe("pnpm");
});

test("yarn classic global", () => {
expect(detectGlobalInstall(`${dirs.yarnPackages}/${BIN}`, dirs)).toEqual({
agent: "yarn",
prefix: "/usr/local",
packagesDir: dirs.yarnPackages,
});
});

test("returns null for non-global paths", () => {
expect(detectGlobalInstall(`${HOME}/proj/paimon/bin/paimon`, dirs)).toBe(
null,
);
// 项目本地 node_modules 不属于任何全局目录形态
expect(
detectGlobalInstall(`${HOME}/proj/paimon/node_modules/${BIN}`, dirs),
).toBe(null);
});
});

describe("buildUpdateCommand", () => {
const spec = "@tyanxie/paimon@1.5.0";

test("npm appends explicit prefix", () => {
const result = buildUpdateCommand(
{ agent: "npm", prefix: "/opt/homebrew", packagesDir: "" },
spec,
);
expect(result).toEqual({
cmd: ["npm", "i", "-g", spec, "--prefix", "/opt/homebrew"],
env: {},
});
});

test("yarn appends explicit prefix", () => {
const result = buildUpdateCommand(
{ agent: "yarn", prefix: "/usr/local", packagesDir: "" },
spec,
);
expect(result?.cmd).toEqual([
"yarn",
"global",
"add",
spec,
"--prefix",
"/usr/local",
]);
expect(result?.env).toEqual({});
});

test("pnpm passes prefix via PNPM_HOME", () => {
const result = buildUpdateCommand(
{ agent: "pnpm", prefix: "/pnpm-home", packagesDir: "" },
spec,
);
expect(result).toEqual({
cmd: ["pnpm", "add", "-g", spec],
env: { PNPM_HOME: "/pnpm-home" },
});
});

test("bun passes prefix via BUN_INSTALL", () => {
const result = buildUpdateCommand(
{ agent: "bun", prefix: "/home/u/.bun", packagesDir: "" },
spec,
);
expect(result).toEqual({
cmd: ["bun", "add", "-g", spec],
env: { BUN_INSTALL: "/home/u/.bun" },
});
});
});
159 changes: 159 additions & 0 deletions src/cli/commands/update/detect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// 全局安装来源检测 —— 纯函数实现,便于测试
//
// 为什么不直接用 global-directory 的 npm.prefix:
// 该库的 npm prefix 兜底逻辑是 dirname(dirname(process.execPath)),前提是「运行在 node 里」。
// paimon 编译模式下 execPath 指向 paimon 二进制自身,推导结果完全错误
// (实测 nvm 环境下会得出 ~/.bun 而非 ~/.nvm/versions/node/vX)。
// 因此 npm / bun 的 prefix 一律从二进制真实所在的 node_modules 反推——
// 二进制躺在哪儿,哪儿就是真值,比 global-directory 和 `npm prefix -g` 都准。
// global-directory 只用于 yarn / pnpm 目录识别(这两者不依赖 execPath,
// 且它处理了 XDG / pnpm rc / darwin 的路径差异)。

import { dirname, resolve } from "node:path";
import { homedir } from "node:os";
import globalDirectory from "global-directory";
import { resolveCommand } from "package-manager-detector/commands";

/** 支持自更新的包管理器 */
export type PackageManager = "npm" | "pnpm" | "yarn" | "bun";

/** 检测到的全局安装信息 */
export interface GlobalInstall {
agent: PackageManager;
/** 全局安装前缀,用于显式指定,避免 PATH 上的 pm 与当初安装的 pm prefix 不一致 */
prefix: string;
/** 全局 node_modules 目录 */
packagesDir: string;
}

/** 各包管理器的已知全局目录(可注入,便于测试) */
export interface KnownGlobalDirs {
/** pnpm 全局根目录 <PNPM_HOME>/global,其下含 <N>/node_modules 与 <N>/.pnpm */
pnpmGlobalRoot: string;
pnpmPackages: string;
pnpmPrefix: string;
yarnPackages: string;
yarnPrefix: string;
bunPrefix: string;
}

/** bun 全局安装目录相对 BUN_INSTALL 的后缀 */
const BUN_GLOBAL_SUFFIX = "install/global/node_modules";
/** npm 全局安装目录相对 prefix 的后缀(POSIX) */
const NPM_GLOBAL_SUFFIX = "lib/node_modules";

/** 读取当前环境下各包管理器的全局目录 */
export function getKnownGlobalDirs(
env: Record<string, string | undefined> = process.env,
): KnownGlobalDirs {
return {
// globalDirectory.pnpm.packages = <global>/<N>/node_modules,上溯两级得全局根目录,
// 这样不依赖 pnpm 的目录版本号(当前为 5,未来可能变化)
pnpmGlobalRoot: dirname(dirname(globalDirectory.pnpm.packages)),
pnpmPackages: globalDirectory.pnpm.packages,
pnpmPrefix: globalDirectory.pnpm.prefix,
yarnPackages: globalDirectory.yarn.packages,
// yarn classic 的 --prefix 对应 <prefix>/bin
yarnPrefix: dirname(globalDirectory.yarn.binaries),
bunPrefix: env.BUN_INSTALL ?? resolve(homedir(), ".bun"),
};
}

/** 判断 child 是否等于 parent 或位于其内部 */
function isInside(child: string, parent: string): boolean {
return child === parent || child.startsWith(`${parent}/`);
}

/** 按目录形态判定某个 node_modules 属于哪个包管理器 */
function classify(
packagesDir: string,
dirs: KnownGlobalDirs,
): GlobalInstall | null {
if (isInside(packagesDir, dirs.yarnPackages)) {
return { agent: "yarn", prefix: dirs.yarnPrefix, packagesDir };
}
if (packagesDir.endsWith(`/${BUN_GLOBAL_SUFFIX}`)) {
return {
agent: "bun",
prefix: packagesDir.slice(0, -(BUN_GLOBAL_SUFFIX.length + 1)),
packagesDir,
};
}
if (packagesDir.endsWith(`/${NPM_GLOBAL_SUFFIX}`)) {
return {
agent: "npm",
prefix: packagesDir.slice(0, -(NPM_GLOBAL_SUFFIX.length + 1)),
packagesDir,
};
}
return null;
}

/**
* 从二进制路径反查全局安装来源。
*
* @param execPath 二进制真实路径(调用方需先 realpath)
*/
export function detectGlobalInstall(
execPath: string,
dirs: KnownGlobalDirs,
): GlobalInstall | null {
// pnpm 必须先于 node_modules 反查处理:它的实包存放在与 node_modules 同级的
// .pnpm 虚拟目录下(<global>/<N>/.pnpm/<pkg>/node_modules/...),
// 从二进制向上找到的 node_modules 并不是全局安装目录,只能靠全局根目录判定。
if (isInside(execPath, dirs.pnpmGlobalRoot)) {
return {
agent: "pnpm",
prefix: dirs.pnpmPrefix,
packagesDir: dirs.pnpmPackages,
};
}

// 其余包管理器为扁平布局,从外向内逐层匹配 node_modules
const segments = execPath.split("/");
for (let i = 0; i < segments.length; i++) {
if (segments[i] !== "node_modules") continue;
const result = classify(segments.slice(0, i + 1).join("/"), dirs);
if (result) return result;
}
return null;
}

/** 更新命令(含需要注入的环境变量) */
export interface UpdateCommand {
cmd: string[];
env: Record<string, string>;
}

/**
* 构造全局更新命令。
*
* 基础 argv 交给 package-manager-detector 生成(它跟随各 pm 演进,
* 例如 yarn berry 已移除 global install,其 global 命令实际回落到 npm),
* 再按 agent 追加显式 prefix,避免装到另一个 prefix 去。
*/
export function buildUpdateCommand(
install: GlobalInstall,
spec: string,
): UpdateCommand | null {
const resolved = resolveCommand(install.agent, "global", [spec]);
if (!resolved) return null;

const cmd = [resolved.command, ...resolved.args];
const env: Record<string, string> = {};

switch (install.agent) {
case "npm":
case "yarn":
cmd.push("--prefix", install.prefix);
break;
case "pnpm":
env.PNPM_HOME = install.prefix;
break;
case "bun":
env.BUN_INSTALL = install.prefix;
break;
}

return { cmd, env };
}
Loading