diff --git a/AGENTS.md b/AGENTS.md index 3d57351..210cb0e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 时区初始化) @@ -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` 虚拟目录(`/global//.pnpm/...`),无法靠 `node_modules` 反查,改为按全局根目录 `/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 `(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 时只下载匹配当前系统的那一个 diff --git a/bun.lock b/bun.lock index 43afc4e..2fb3525 100644 --- a/bun.lock +++ b/bun.lock @@ -7,10 +7,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", @@ -526,6 +528,8 @@ "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], + "global-directory": ["global-directory@5.0.0", "", { "dependencies": { "ini": "6.0.0" } }, "sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w=="], + "google-auth-library": ["google-auth-library@10.6.2", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw=="], "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], @@ -574,6 +578,8 @@ "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + "ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], @@ -762,6 +768,8 @@ "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], + "package-manager-detector": ["package-manager-detector@1.8.0", "", {}, "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A=="], + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], diff --git a/package.json b/package.json index 02dccf3..9ff01b7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/cli/commands/update/detect.test.ts b/src/cli/commands/update/detect.test.ts new file mode 100644 index 0000000..f4bfcbd --- /dev/null +++ b/src/cli/commands/update/detect.test.ts @@ -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" }, + }); + }); +}); diff --git a/src/cli/commands/update/detect.ts b/src/cli/commands/update/detect.ts new file mode 100644 index 0000000..d5ef95f --- /dev/null +++ b/src/cli/commands/update/detect.ts @@ -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 全局根目录 /global,其下含 /node_modules 与 /.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 = process.env, +): KnownGlobalDirs { + return { + // globalDirectory.pnpm.packages = //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 对应 /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 虚拟目录下(//.pnpm//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; +} + +/** + * 构造全局更新命令。 + * + * 基础 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 = {}; + + 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 }; +} diff --git a/src/cli/commands/update/global.ts b/src/cli/commands/update/global.ts new file mode 100644 index 0000000..2681d42 --- /dev/null +++ b/src/cli/commands/update/global.ts @@ -0,0 +1,120 @@ +// 编译模式(全局安装)的更新:检测安装来源 → 委派对应包管理器 +// +// 不做 rustup 式「下载二进制自替换」:paimon 走 esbuild 式平台包分发, +// npm/bun/pnpm 会按 optionalDependencies 解析出正确的平台包; +// 自替换会让包管理器元数据与磁盘文件脱节,且要自行处理校验、回滚等复杂度。 + +import { realpathSync, accessSync, constants } from "node:fs"; +import pkg from "../../../../package.json"; +import { + detectGlobalInstall, + getKnownGlobalDirs, + buildUpdateCommand, + type GlobalInstall, +} from "./detect"; +import { runInherit, printDaemonHints } from "./utils"; + +const PACKAGE_NAME = pkg.name; // @tyanxie/paimon +const NPM_REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`; +/** pi 插件的安装源(与 README 中 `pi install npm:@tyanxie/paimon` 一致) */ +const PI_EXTENSION_SOURCE = `npm:${PACKAGE_NAME}`; + +/** 从 npm registry 获取最新版本号 */ +async function fetchLatestVersion(): Promise { + try { + const resp = await fetch(NPM_REGISTRY_URL, { + signal: AbortSignal.timeout(10_000), + headers: { Accept: "application/json" }, + }); + if (!resp.ok) return null; + const data = (await resp.json()) as { version: string }; + return data.version ?? null; + } catch { + return null; + } +} + +/** 比较语义化版本:返回 true 表示 latest 比 current 更新 */ +function isNewerVersion(current: string, latest: string): boolean { + const parseSemver = (v: string) => v.split(".").map(Number); + const [cMajor, cMinor, cPatch] = parseSemver(current); + const [lMajor, lMinor, lPatch] = parseSemver(latest); + if (lMajor !== cMajor) return lMajor > cMajor; + if (lMinor !== cMinor) return lMinor > cMinor; + return lPatch > cPatch; +} + +/** 检测安装来源,失败则给出明确的手动更新指引并退出 */ +function resolveInstall(): GlobalInstall { + let execPath = process.execPath; + try { + execPath = realpathSync(execPath); + } catch { + // 保留原值继续尝试 + } + + const install = detectGlobalInstall(execPath, getKnownGlobalDirs()); + if (!install) { + console.error( + `Unable to determine how paimon was installed (${execPath}).`, + ); + console.error("Please update manually with your package manager, e.g.:"); + console.error(` npm install -g ${PACKAGE_NAME}@latest`); + process.exit(1); + } + return install; +} + +/** 安装失败后的补充提示:prefix 不可写通常意味着需要 sudo */ +function printFailureHint(install: GlobalInstall, cmd: string[]): void { + try { + accessSync(install.prefix, constants.W_OK); + } catch { + console.error( + `\n${install.prefix} is not writable. Try again with elevated privileges:`, + ); + console.error(` sudo ${cmd.join(" ")}`); + } +} + +/** 全局安装模式:检查并执行更新 */ +export async function updateGlobalInstall(checkOnly: boolean): Promise { + const install = resolveInstall(); + + // 查询最新版本 + console.log("Checking for updates..."); + const latest = await fetchLatestVersion(); + if (!latest) { + console.error("Failed to fetch latest version from npm registry"); + process.exit(1); + } + + if (!isNewerVersion(pkg.version, latest)) { + console.log(`Already up to date (v${pkg.version})`); + return; + } + + console.log(`Current: v${pkg.version} → Latest: v${latest}`); + console.log(`Installed via ${install.agent} at ${install.prefix}`); + + if (checkOnly) return; + + const command = buildUpdateCommand(install, `${PACKAGE_NAME}@${latest}`); + if (!command) { + console.error(`${install.agent} does not support global install`); + process.exit(1); + } + + console.log(`\n${command.cmd.join(" ")}`); + if (!(await runInherit(command.cmd, { env: command.env }))) { + console.error("\nUpdate failed"); + printFailureHint(install, command.cmd); + process.exit(1); + } + + console.log(`\n✅ Updated to v${latest}`); + await printDaemonHints(); + console.log( + ` Run 'pi update --extension ${PI_EXTENSION_SOURCE}' to update pi extension`, + ); +} diff --git a/src/cli/commands/update/index.ts b/src/cli/commands/update/index.ts new file mode 100644 index 0000000..7e55a32 --- /dev/null +++ b/src/cli/commands/update/index.ts @@ -0,0 +1,26 @@ +// paimon update —— 自更新命令 +// +// 两种安装模式: +// 源码模式(bun link):git pull + bun install + bun run build +// 编译模式(全局安装):检测 npm/pnpm/yarn/bun 来源后委派对应包管理器 + +import type { Command } from "@commander-js/extra-typings"; +import { isCompiled } from "../../../utils/env"; +import { updateFromSource } from "./source"; +import { updateGlobalInstall } from "./global"; + +export function registerUpdateCommand(program: Command): void { + program + .command("update") + .description("update paimon to the latest version") + .option("--check", "check for updates without installing") + .action(async (options) => { + const checkOnly = options.check ?? false; + + if (isCompiled) { + await updateGlobalInstall(checkOnly); + } else { + await updateFromSource(checkOnly); + } + }); +} diff --git a/src/cli/commands/update/source.ts b/src/cli/commands/update/source.ts new file mode 100644 index 0000000..cc765e9 --- /dev/null +++ b/src/cli/commands/update/source.ts @@ -0,0 +1,108 @@ +// 源码模式(bun link)的更新:git pull + bun install + bun run build + +import { resolve } from "node:path"; +import pkg from "../../../../package.json"; +import { run, runInherit, printDaemonHints } from "./utils"; + +/** 获取仓库根目录(从当前文件位置推导) */ +function getRepoRoot(): string { + // 当前文件: /src/cli/commands/update/source.ts + return resolve(import.meta.dirname!, "../../../.."); +} + +/** 源码模式:检查并执行更新 */ +export async function updateFromSource(checkOnly: boolean): Promise { + const repoRoot = getRepoRoot(); + + // 1. 检查工作目录是否干净 + const status = await run(["git", "status", "--porcelain"], { cwd: repoRoot }); + if (!status.ok) { + console.error("Failed to check git status"); + process.exit(1); + } + if (status.stdout) { + console.error("Working directory has uncommitted changes:"); + console.error(status.stdout); + console.error("\nPlease commit or stash changes before updating."); + process.exit(1); + } + + // 2. fetch 远端 + console.log("Fetching updates..."); + const fetchResult = await run(["git", "fetch"], { cwd: repoRoot }); + if (!fetchResult.ok) { + console.error("Failed to fetch remote:", fetchResult.stderr); + process.exit(1); + } + + // 3. 比较本地与远端 HEAD + const [localResult, remoteResult] = await Promise.all([ + run(["git", "rev-parse", "HEAD"], { cwd: repoRoot }), + run(["git", "rev-parse", "@{upstream}"], { cwd: repoRoot }), + ]); + + if (!localResult.ok || !remoteResult.ok) { + console.error("Failed to compare local and remote HEAD"); + process.exit(1); + } + + const localHash = localResult.stdout; + const remoteHash = remoteResult.stdout; + + if (localHash === remoteHash) { + console.log( + `Already up to date (v${pkg.version}, ${localHash.slice(0, 7)})`, + ); + return; + } + + // 检查是否可以 fast-forward(远端领先本地) + const ancestor = await run( + ["git", "merge-base", "--is-ancestor", "HEAD", "@{upstream}"], + { cwd: repoRoot }, + ); + if (!ancestor.ok) { + console.error( + "Local branch has diverged from upstream (local commits not pushed).", + ); + console.error("Please push or reset your changes before updating."); + process.exit(1); + } + + // 显示远端版本信息 + const remoteVersionResult = await run( + ["git", "show", `${remoteHash}:package.json`], + { cwd: repoRoot }, + ); + const remoteVersion = remoteVersionResult.ok + ? (JSON.parse(remoteVersionResult.stdout) as { version: string }).version + : "unknown"; + + console.log( + `Current: v${pkg.version} (${localHash.slice(0, 7)}) → Remote: v${remoteVersion} (${remoteHash.slice(0, 7)})`, + ); + + if (checkOnly) return; + + // 4. 执行更新 + console.log("\nPulling changes..."); + if (!(await runInherit(["git", "pull"], { cwd: repoRoot }))) { + console.error("git pull failed"); + process.exit(1); + } + + console.log("\nInstalling dependencies..."); + if (!(await runInherit(["bun", "install"], { cwd: repoRoot }))) { + console.error("bun install failed"); + process.exit(1); + } + + console.log("\nBuilding web UI..."); + if (!(await runInherit(["bun", "run", "build"], { cwd: repoRoot }))) { + console.error("bun run build failed"); + process.exit(1); + } + + console.log(`\n✅ Updated to v${remoteVersion} (${remoteHash.slice(0, 7)})`); + await printDaemonHints(); +} diff --git a/src/cli/commands/update/utils.ts b/src/cli/commands/update/utils.ts new file mode 100644 index 0000000..5dd4a60 --- /dev/null +++ b/src/cli/commands/update/utils.ts @@ -0,0 +1,64 @@ +// update 命令的共享工具 + +import { getDaemonStatus } from "../../daemon"; +import { getEdgeDaemonStatus } from "../../edge-daemon"; + +/** 子进程执行选项 */ +export interface RunOptions { + cwd?: string; + env?: Record; +} + +/** 运行命令并捕获输出 */ +export async function run( + cmd: string[], + options?: RunOptions, +): Promise<{ ok: boolean; stdout: string; stderr: string }> { + const proc = Bun.spawn(cmd, { + cwd: options?.cwd, + env: options?.env ? { ...process.env, ...options.env } : undefined, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + const exitCode = await proc.exited; + return { ok: exitCode === 0, stdout: stdout.trim(), stderr: stderr.trim() }; +} + +/** + * 运行命令并将 stdio 直接透传到终端。 + * stdin 也需要 inherit:git pull 可能要求输入凭证, + * 全局安装到 /usr/local 时 sudo 可能要求输入密码。 + */ +export async function runInherit( + cmd: string[], + options?: RunOptions, +): Promise { + const proc = Bun.spawn(cmd, { + cwd: options?.cwd, + env: options?.env ? { ...process.env, ...options.env } : undefined, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }); + const exitCode = await proc.exited; + return exitCode === 0; +} + +/** 检查 Hub/Edge 运行状态并输出重启提示 */ +export async function printDaemonHints(): Promise { + const [hub, edge] = await Promise.all([ + getDaemonStatus(), + getEdgeDaemonStatus(), + ]); + + if (hub.running) { + console.log(" Run 'paimon hub restart' to apply updates to Hub"); + } + if (edge.running) { + console.log(" Run 'paimon edge restart' to apply updates to Edge"); + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 4ab8937..cd40813 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -25,6 +25,7 @@ if (role === "hub") { const { registerAttachCommand } = await import("./commands/attach"); const { registerVersionCommand, version } = await import("./commands/version"); + const { registerUpdateCommand } = await import("./commands/update"); program .name("paimon") @@ -35,6 +36,7 @@ if (role === "hub") { registerEdgeCommand(program); registerAttachCommand(program); registerVersionCommand(program); + registerUpdateCommand(program); program.parse(); }