From 6cf19616188a13528894071ddcc3a5181e3a5edd Mon Sep 17 00:00:00 2001 From: aMgLn Date: Wed, 2 Sep 2026 17:13:22 +0900 Subject: [PATCH 1/2] =?UTF-8?q?CLI/=ED=94=8C=EB=9F=AC=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EB=A6=B4=EB=A6=AC=EC=8A=A4=20=EA=B0=90=EC=A7=80=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - packages/cli/src/core/update.server.ts 신규: GitHub 최신 릴리스 조회, 버전 비교(단순 문자열 비교가 아닌 숫자 필드별 비교), 24시간 캐시된 백그라운드 체크(3초 타임아웃, 실패해도 절대 던지지 않음), 플랫폼별 바이너리 자체 교체 로직(Windows는 실행 중인 exe를 덮어쓰거나 지울 수 없어 .old.exe로 먼저 rename한 뒤 새 바이너리를 원래 이름으로 rename하는 방식 사용) - sessionforge check-update / sessionforge update 명령 추가 — 감지는 자동(다른 모든 명령 끝에 한 줄 알림)이지만 실제 교체는 항상 명시적 명령 으로만 — 사용자 동의 없이 실행 중인 바이너리를 몰래 바꾸지 않음 - 실제로 검증함: 별도로 복사한 사본에서 v0.1.0 → 진짜 v0.2.0 GitHub Release로 실제 다운로드 및 자체 교체 성공, 교체된 바이너리 정상 동작 확인 - scripts/package-plugin.mjs가 패키징된 플러그인 번들에 .sessionforge-version 마커 파일을 기록하도록 함 (release.yml이 SESSIONFORGE_VERSION 전달) - paseo-wire.server.ts에 getInstalledPluginVersion 추가, paseo-status가 설치된 플러그인 버전과 실행 중인 CLI 버전의 드리프트를 보여주도록 확장 (dev-main 빌드에서는 비교 자체가 의미 없으므로 드리프트 안내를 생략함) — 실제 바이너리로 드리프트 감지와 "unknown"(마커 없는 구버전 설치) 케이스 모두 실제로 확인함 - 테스트 31개 추가(update.server 19개, paseo-wire 2개), 전체 121개 통과 - README.md, README.ko.md, docs/MANUAL.md(새 "Staying up to date" 섹션), docs/REFERENCE.md, packages/cli/README.md 문서 갱신 --- .github/workflows/release.yml | 3 + README.ko.md | 4 + README.md | 3 + docs/MANUAL.md | 27 +- docs/REFERENCE.md | 4 + packages/cli/README.md | 8 + packages/cli/src/cli/bin.ts | 107 +++++++- .../cli/src/core/paseo-wire.server.test.ts | 14 ++ packages/cli/src/core/paseo-wire.server.ts | 17 ++ packages/cli/src/core/update.server.test.ts | 236 ++++++++++++++++++ packages/cli/src/core/update.server.ts | 146 +++++++++++ scripts/package-plugin.mjs | 12 +- 12 files changed, 568 insertions(+), 13 deletions(-) create mode 100644 packages/cli/src/core/update.server.test.ts create mode 100644 packages/cli/src/core/update.server.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 13e408d..ddc4ed6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -84,6 +84,7 @@ jobs: # like the CLI binaries above, so it only needs one job. `sessionforge wire-paseo` downloads this exact # asset and points a local `paseo plugin install` at it. package-plugin: + needs: version runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -95,6 +96,8 @@ jobs: - run: npm install - run: npm run package:plugin + env: + SESSIONFORGE_VERSION: ${{ needs.version.outputs.version }} - uses: actions/upload-artifact@v4 with: diff --git a/README.ko.md b/README.ko.md index d5d58a9..55f1ab7 100644 --- a/README.ko.md +++ b/README.ko.md @@ -55,6 +55,10 @@ sessionforge wire-paseo `paseo plugin install /path/to/sessionforge`(저장소 클론과 Node.js 22.16 이상 필요)는 [`docs/MANUAL.md`](docs/MANUAL.md#installing-the-paseo-plugin)를 참고하세요. +`sessionforge`는 새 릴리스가 나오면 자동으로 감지해 알려줍니다 — 업데이트하려면 +`sessionforge update`를 실행하세요. 자세한 내용은 +[`docs/MANUAL.md`](docs/MANUAL.md#staying-up-to-date)를 참고하세요. + npm에는 배포하지 않습니다 — 독립 바이너리와 Paseo 플러그인이 유일한 배포 경로입니다. ## 문서 diff --git a/README.md b/README.md index d0e4f11..dde3c18 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,9 @@ See [`docs/MANUAL.md`](docs/MANUAL.md#installing-the-paseo-plugin) for details, and the manual `paseo plugin install /path/to/sessionforge` alternative (only needed for plugin development — it requires cloning the repo and Node.js 22.16+, unlike `wire-paseo`). +`sessionforge` checks for new releases automatically and tells you when one's available; run +`sessionforge update` to install it. See [`docs/MANUAL.md`](docs/MANUAL.md#staying-up-to-date). + Not published to npm — the standalone binary and the Paseo plugin are the only distribution channels. ## Documentation diff --git a/docs/MANUAL.md b/docs/MANUAL.md index 44ec47f..4b3a071 100644 --- a/docs/MANUAL.md +++ b/docs/MANUAL.md @@ -10,6 +10,7 @@ see [`packages/cli/README.md`](../packages/cli/README.md). - [Installing the standalone binary](#installing-the-standalone-binary) - [Aider (opt-in) setup](#aider-opt-in-setup) - [Installing the Paseo plugin](#installing-the-paseo-plugin) +- [Staying up to date](#staying-up-to-date) - [Safety model](#safety-model) - [Platform support](#platform-support) @@ -25,7 +26,9 @@ sessionforge archive [--reason ...] # move a session out of the active vi sessionforge restore [--reason ...] # bring an archived/trashed session back sessionforge audit [id] [--json] # show the audit trail for destructive operations sessionforge wire-paseo [--version ...] # download and install the Paseo plugin (see below) -sessionforge paseo-status # check whether the Paseo plugin is installed and running +sessionforge paseo-status # check plugin install/running status and version drift +sessionforge check-update # check whether a newer sessionforge release is available +sessionforge update # download and install the latest release, replacing this binary ``` `list` flags: `--agent`, `--project`, `--status`, `--lifecycle`, `--category`, `--older-than 30d`, @@ -126,6 +129,28 @@ per-session/per-provider on-disk size. It re-scans Claude Code, Codex, Gemini CL every 5 minutes in the background (Aider is included in that rescan too, but only once `AIDER_SEARCH_ROOTS` is set). +## Staying up to date + +Detection is automatic, applying it is not: every command other than `check-update`/`update`/`--version` +does a cached (once per 24h), silently-fails-safe background check against the latest GitHub Release, and +prints a one-line notice at the end if a newer version exists — + +``` +(sessionforge v0.3.0 is available — run `sessionforge update` to install it.) +``` + +— but nothing is ever downloaded or replaced without you explicitly running: + +```bash +sessionforge check-update # just check, no download +sessionforge update # download the latest binary and replace this one in place +``` + +`update` only works on an actual release binary — a local/dev build (`sessionforge --version` prints +`dev-main`) has nothing for it to replace; use `git pull` instead. After updating the CLI, run +`sessionforge wire-paseo` again to pull the matching plugin version too — `sessionforge paseo-status` shows +you if the installed plugin has drifted from the CLI's own version. + ## Safety model - Adapters are **read-only** for discovery: `ClaudeCodeAdapter` only reads `~/.claude/projects/**/*.jsonl`, diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 2b2554e..77c0ed4 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -35,6 +35,10 @@ packages/cli/ @aadaa88/sessionforge — the standalone pack store.server.ts SQLite persistence + FTS5 ranked search (node:sqlite, ~/.sessionforge/sessionforge.db) paseo-wire.server.ts downloads + installs the Paseo plugin via `paseo plugin install` — powers `wire-paseo`/ `paseo-status`; only reads the daemon's pluginsEnabled setting, never writes it + update.server.ts GitHub-releases-backed self-update: version comparison, a 24h-cached background + check, and the OS-specific rename dance that replaces a running binary in place + (Windows can't overwrite/delete a running .exe, only rename it) — powers + `check-update`/`update` discover.server.ts orchestrates adapters -> activity -> classify -> summarize -> store -> relationships lifecycle-actions.server.ts archive/restore/delete/cleanup + audit log src/cli/ the `sessionforge` CLI itself, imports ../core directly — no daemon needed diff --git a/packages/cli/README.md b/packages/cli/README.md index 6857bda..cbabe63 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -34,8 +34,16 @@ sessionforge cleanup --apply # move current JUNK candidates to trash (stil sessionforge archive --reason "done" sessionforge restore sessionforge audit [session-id] +sessionforge wire-paseo # download and install the Paseo plugin +sessionforge paseo-status # plugin install/running status and version drift +sessionforge check-update # check for a newer release +sessionforge update # download and install the latest release, replacing this binary ``` +Every command other than `check-update`/`update`/`--version` does a cached (once per 24h), +silently-fails-safe background check for a newer release and prints a one-line notice if one's available — +see the root [MANUAL.md](../../docs/MANUAL.md#staying-up-to-date) for details. + Aider sessions are opt-in: set `AIDER_SEARCH_ROOTS` to a list of directories to search, delimited the same way `PATH` is on your OS (`:` on Linux/macOS, `;` on Windows) — Aider has no central session directory, unlike the other four tools — e.g.: diff --git a/packages/cli/src/cli/bin.ts b/packages/cli/src/cli/bin.ts index d8f6150..01bd971 100755 --- a/packages/cli/src/cli/bin.ts +++ b/packages/cli/src/cli/bin.ts @@ -13,6 +13,7 @@ import { DEFAULT_PLUGIN_ID, downloadPluginArchive, extractPluginArchive, + getInstalledPluginVersion, getPluginStatus, installPluginDirectory, isPaseoCliAvailable, @@ -21,6 +22,7 @@ import { } from "../core/paseo-wire.server.js"; import { SessionStore } from "../core/store.server.js"; import type { AgentId, ClassificationCategory, SessionLifecycle, SessionStatus } from "../core/types.server.js"; +import { checkForUpdateCached, downloadCliBinary, getLatestReleaseVersion, isUpdateAvailable, selfReplaceBinary } from "../core/update.server.js"; import { formatSessionDetail, formatSessionTable, parseOlderThan } from "./format.js"; const ADAPTERS = [new ClaudeCodeAdapter(), new CodexAdapter(), new GeminiCliAdapter(), new OpenCodeAdapter(), new AiderAdapter()]; @@ -308,6 +310,64 @@ async function cmdPaseoStatus(): Promise { } console.log(`${status.id}: ${status.status}${status.enabled ? "" : " (disabled)"} — ${status.path}`); if (status.error) console.log(`Error: ${status.error}`); + + const installedVersion = await getInstalledPluginVersion(status.path); + const cliVersion = getVersion(); + if (installedVersion === null) { + console.log("Plugin version: unknown (not installed via `wire-paseo` — no version marker present)."); + } else if (installedVersion !== cliVersion && cliVersion !== DEV_VERSION) { + console.log(`Plugin version: ${installedVersion} (this CLI is v${cliVersion} — run \`sessionforge wire-paseo\` to update it).`); + } else { + console.log(`Plugin version: ${installedVersion}`); + } +} + +async function cmdCheckUpdate(): Promise { + const current = getVersion(); + if (current === DEV_VERSION) { + console.log(`Running a development build (${DEV_VERSION}) — version checks don't apply.`); + return; + } + + console.log(`Current version: ${current}`); + const latest = await getLatestReleaseVersion(); + if (isUpdateAvailable(current, latest)) { + console.log(`A new version is available: ${latest}`); + console.log("Run `sessionforge update` to install it."); + } else { + console.log("You're on the latest version."); + } +} + +/** Downloads the latest platform-matched binary and replaces the currently-running one in place — see + * `selfReplaceBinary` for why that needs OS-specific handling rather than a plain overwrite. */ +async function cmdUpdate(): Promise { + const current = getVersion(); + if (current === DEV_VERSION) { + console.error( + `This is a development build (${DEV_VERSION}), not an installed release binary — there's nothing ` + + "for this command to replace. If you're running from a clone, use `git pull` instead.", + ); + process.exitCode = 1; + return; + } + + console.log(`Current version: ${current}`); + const latest = await getLatestReleaseVersion(); + if (!isUpdateAvailable(current, latest)) { + console.log("Already on the latest version."); + return; + } + + const isWindows = process.platform === "win32"; + const newBinaryPath = join(tmpdir(), `sessionforge-update-${latest}${isWindows ? ".exe" : ""}`); + console.log(`Downloading v${latest}...`); + await downloadCliBinary(latest, newBinaryPath); + + console.log("Installing..."); + await selfReplaceBinary(newBinaryPath, process.execPath); + + console.log(`Updated to v${latest}. Run \`sessionforge wire-paseo\` too if you use the Paseo plugin, to keep it in sync.`); } function printHelp(): void { @@ -323,22 +383,19 @@ Usage: sessionforge restore [--reason ...] bring an archived/trashed session back sessionforge audit [id] [--json] show the audit trail for destructive operations sessionforge wire-paseo [--version ...] download and install the Paseo plugin for this CLI's version - sessionforge paseo-status show whether the Paseo plugin is installed and running + sessionforge paseo-status show whether the Paseo plugin is installed and running, and + whether its version has drifted from this CLI's own + sessionforge check-update check whether a newer sessionforge release is available + sessionforge update download and install the latest release, replacing this binary sessionforge --version print this CLI's own version `); } -async function main(): Promise { - const args = parseArgs(process.argv.slice(2)); - const command = args.positional.shift(); - - // parseArgs treats any "--xxx" token as a flag regardless of position, so `sessionforge --version` never - // reaches the switch below as a positional "--version" — it lands here instead, with command undefined. - if (command === undefined && args.flags.get("version")) { - console.log(getVersion()); - return; - } +// Commands that already report version/update info themselves — piling the same background-check notice +// on top would just be noise. +const SKIP_UPDATE_NOTICE = new Set(["check-update", "update", "version", "-v", "help", "--help", "-h", undefined]); +async function runCommand(command: string | undefined, args: ParsedArgs): Promise { switch (command) { case "discover": return cmdDiscover(); @@ -360,6 +417,10 @@ async function main(): Promise { return cmdWirePaseo(args); case "paseo-status": return cmdPaseoStatus(); + case "check-update": + return cmdCheckUpdate(); + case "update": + return cmdUpdate(); case "version": case "-v": console.log(getVersion()); @@ -376,6 +437,30 @@ async function main(): Promise { } } +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + const command = args.positional.shift(); + + // parseArgs treats any "--xxx" token as a flag regardless of position, so `sessionforge --version` never + // reaches runCommand's switch as a positional "--version" — it lands here instead, with command undefined. + if (command === undefined && args.flags.get("version")) { + console.log(getVersion()); + return; + } + + await runCommand(command, args); + + // Release detection is automatic (a cached, rate-limited, silently-fails-safe background check on every + // other command); actually applying it stays a deliberate, explicit `sessionforge update` — replacing a + // running binary out from under the user without asking is the kind of surprise a CLI shouldn't spring. + if (!SKIP_UPDATE_NOTICE.has(command)) { + const check = await checkForUpdateCached(getVersion()); + if (check?.updateAvailable) { + console.log(`\n(sessionforge v${check.latestVersion} is available — run \`sessionforge update\` to install it.)`); + } + } +} + main().catch((error: unknown) => { console.error(error instanceof Error ? error.message : error); process.exitCode = 1; diff --git a/packages/cli/src/core/paseo-wire.server.test.ts b/packages/cli/src/core/paseo-wire.server.test.ts index 13f1b28..29b2388 100644 --- a/packages/cli/src/core/paseo-wire.server.test.ts +++ b/packages/cli/src/core/paseo-wire.server.test.ts @@ -16,6 +16,7 @@ const { arePluginsEnabled, downloadPluginArchive, extractPluginArchive, + getInstalledPluginVersion, getPluginStatus, installPluginDirectory, isPaseoCliAvailable, @@ -164,4 +165,17 @@ describe("paseo-wire", () => { expect(pluginInstallDir()).toContain("paseo-plugin"); }); }); + + describe("getInstalledPluginVersion", () => { + it("reads the version marker scripts/package-plugin.mjs bakes into the packaged bundle", async () => { + const { writeFile } = await import("node:fs/promises"); + await writeFile(join(root, ".sessionforge-version"), "0.3.0\n"); + + expect(await getInstalledPluginVersion(root)).toBe("0.3.0"); + }); + + it("returns null for a plugin installed the manual way, with no version marker", async () => { + expect(await getInstalledPluginVersion(root)).toBeNull(); + }); + }); }); diff --git a/packages/cli/src/core/paseo-wire.server.ts b/packages/cli/src/core/paseo-wire.server.ts index d0a317c..e802e25 100644 --- a/packages/cli/src/core/paseo-wire.server.ts +++ b/packages/cli/src/core/paseo-wire.server.ts @@ -94,3 +94,20 @@ export async function getPluginStatus(id: string = DEFAULT_PLUGIN_ID): Promise

{ + try { + return (await readFile(join(pluginDir, PLUGIN_VERSION_FILE), "utf8")).trim(); + } catch { + return null; + } +} diff --git a/packages/cli/src/core/update.server.test.ts b/packages/cli/src/core/update.server.test.ts new file mode 100644 index 0000000..ad21c1d --- /dev/null +++ b/packages/cli/src/core/update.server.test.ts @@ -0,0 +1,236 @@ +import { statSync, writeFileSync } from "node:fs"; +import { mkdtemp as mkdtempAsync, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + checkForUpdateCached, + compareVersions, + downloadCliBinary, + getLatestReleaseVersion, + isUpdateAvailable, + selfReplaceBinary, + targetTriple, +} from "./update.server.js"; + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { value: platform, configurable: true }); +} + +describe("compareVersions", () => { + it("compares numerically, not lexicographically", () => { + expect(compareVersions("0.10.0", "0.2.0")).toBeGreaterThan(0); + expect(compareVersions("0.2.0", "0.10.0")).toBeLessThan(0); + expect(compareVersions("1.2.3", "1.2.3")).toBe(0); + }); + + it("treats a missing trailing component as 0", () => { + expect(compareVersions("1.2", "1.2.0")).toBe(0); + expect(compareVersions("1.3", "1.2.9")).toBeGreaterThan(0); + }); +}); + +describe("isUpdateAvailable", () => { + it("is true when latest is numerically newer", () => { + expect(isUpdateAvailable("0.2.0", "0.3.0")).toBe(true); + }); + + it("is false when already on the latest version", () => { + expect(isUpdateAvailable("0.3.0", "0.3.0")).toBe(false); + }); + + it("is false when somehow ahead of the latest release", () => { + expect(isUpdateAvailable("0.4.0", "0.3.0")).toBe(false); + }); + + it("is always true for a dev-main build — any real release counts as newer", () => { + expect(isUpdateAvailable("dev-main", "0.0.1")).toBe(true); + }); +}); + +describe("targetTriple", () => { + const originalPlatform = process.platform; + const originalArch = process.arch; + + afterEach(() => { + setPlatform(originalPlatform); + Object.defineProperty(process, "arch", { value: originalArch, configurable: true }); + }); + + function setArch(arch: string): void { + Object.defineProperty(process, "arch", { value: arch, configurable: true }); + } + + it("maps linux/x64 to the gnu triple", () => { + setPlatform("linux"); + setArch("x64"); + expect(targetTriple()).toBe("x86_64-unknown-linux-gnu"); + }); + + it("maps win32/x64 to the msvc triple", () => { + setPlatform("win32"); + setArch("x64"); + expect(targetTriple()).toBe("x86_64-pc-windows-msvc"); + }); + + it("throws on an unsupported combination rather than guessing", () => { + setPlatform("linux"); + setArch("ia32"); + expect(() => targetTriple()).toThrow(/Unsupported/); + }); +}); + +describe("getLatestReleaseVersion", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("strips the leading 'v' from the release tag", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(JSON.stringify({ tag_name: "v0.3.0" }), { status: 200 })), + ); + expect(await getLatestReleaseVersion()).toBe("0.3.0"); + }); + + it("throws with the status on a failed request", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(null, { status: 404, statusText: "Not Found" })), + ); + await expect(getLatestReleaseVersion()).rejects.toThrow(/404/); + }); +}); + +describe("downloadCliBinary", () => { + let root: string; + + beforeEach(async () => { + root = await mkdtempAsync(join(tmpdir(), "sessionforge-update-dl-")); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + vi.unstubAllGlobals(); + }); + + it("downloads the platform-matched asset and makes it executable on non-Windows", async () => { + setPlatform("linux"); + Object.defineProperty(process, "arch", { value: "x64", configurable: true }); + + const fetchMock = vi.fn(async (url: string) => { + expect(url).toBe("https://github.com/4mGLn/sessionforge/releases/download/v0.3.0/sessionforge-x86_64-unknown-linux-gnu"); + return new Response("fake binary contents", { status: 200 }); + }); + vi.stubGlobal("fetch", fetchMock); + + const destPath = join(root, "sessionforge"); + await downloadCliBinary("0.3.0", destPath); + + expect(await readFile(destPath, "utf8")).toBe("fake binary contents"); + expect(statSync(destPath).mode & 0o111).not.toBe(0); // executable bit set + }); + + it("throws with the status when the download fails", async () => { + setPlatform("linux"); + Object.defineProperty(process, "arch", { value: "x64", configurable: true }); + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(null, { status: 404, statusText: "Not Found" })), + ); + + await expect(downloadCliBinary("9.9.9", join(root, "sessionforge"))).rejects.toThrow(/404/); + }); +}); + +describe("selfReplaceBinary", () => { + const originalPlatform = process.platform; + let root: string; + + beforeEach(async () => { + root = await mkdtempAsync(join(tmpdir(), "sessionforge-update-replace-")); + }); + + afterEach(async () => { + setPlatform(originalPlatform); + await rm(root, { recursive: true, force: true }); + }); + + it("renames the new binary over the current one on linux/macos", async () => { + setPlatform("linux"); + const current = join(root, "sessionforge"); + const incoming = join(root, "sessionforge-new"); + writeFileSync(current, "old"); + writeFileSync(incoming, "new"); + + await selfReplaceBinary(incoming, current); + + expect(await readFile(current, "utf8")).toBe("new"); + }); + + it("renames the running exe aside first on windows, since it can't be overwritten directly", async () => { + setPlatform("win32"); + const current = join(root, "sessionforge.exe"); + const incoming = join(root, "sessionforge-new.exe"); + writeFileSync(current, "old"); + writeFileSync(incoming, "new"); + + await selfReplaceBinary(incoming, current); + + expect(await readFile(current, "utf8")).toBe("new"); + }); +}); + +describe("checkForUpdateCached", () => { + let root: string; + let previousHome: string | undefined; + + beforeEach(async () => { + root = await mkdtempAsync(join(tmpdir(), "sessionforge-update-cache-")); + previousHome = process.env.HOME; + process.env.HOME = root; + }); + + afterEach(async () => { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + await rm(root, { recursive: true, force: true }); + vi.unstubAllGlobals(); + }); + + it("returns null immediately for a dev-main build, without any network call", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + expect(await checkForUpdateCached("dev-main")).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("performs a real check and reports an available update", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(JSON.stringify({ tag_name: "v0.5.0" }), { status: 200 })), + ); + + expect(await checkForUpdateCached("0.3.0")).toEqual({ latestVersion: "0.5.0", updateAvailable: true }); + }); + + it("reuses the cached result within the TTL instead of hitting the network again", async () => { + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ tag_name: "v0.5.0" }), { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + await checkForUpdateCached("0.3.0"); + await checkForUpdateCached("0.3.0"); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("fails safe (returns null) on a network error rather than throwing", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("network unreachable"); + }), + ); + + expect(await checkForUpdateCached("0.3.0")).toBeNull(); + }); +}); diff --git a/packages/cli/src/core/update.server.ts b/packages/cli/src/core/update.server.ts new file mode 100644 index 0000000..83dd17b --- /dev/null +++ b/packages/cli/src/core/update.server.ts @@ -0,0 +1,146 @@ +import { chmodSync, createWriteStream } from "node:fs"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { arch, homedir, platform } from "node:os"; +import { join } from "node:path"; +import { pipeline } from "node:stream/promises"; +import { Readable } from "node:stream"; + +const REPO = "4mGLn/sessionforge"; +const UPDATE_CHECK_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours — one real network check per day at most +const BACKGROUND_CHECK_TIMEOUT_MS = 3000; // a background nice-to-have shouldn't stall an unrelated command + +function updateCheckCachePath(): string { + return join(homedir(), ".sessionforge", "update-check.json"); +} + +/** Same target-triple naming release.yml's matrix and install.sh use — reimplemented here (rather than + * imported from build-binary.mjs, a build-time-only script not part of the runtime bundle) since it's + * only a few lines and this needs the actual runtime platform/arch, not a build script's. */ +export function targetTriple(): string { + const p = platform(); + const a = arch(); + if (p === "linux" && a === "x64") return "x86_64-unknown-linux-gnu"; + if (p === "linux" && a === "arm64") return "aarch64-unknown-linux-gnu"; + if (p === "darwin" && a === "x64") return "x86_64-apple-darwin"; + if (p === "darwin" && a === "arm64") return "aarch64-apple-darwin"; + if (p === "win32" && a === "x64") return "x86_64-pc-windows-msvc"; + throw new Error(`Unsupported platform/arch combination for self-update: ${p}/${a}`); +} + +/** Compares two dotted-numeric version strings (e.g. "0.10.2" vs "0.2.9") field by field, not + * lexicographically — a plain string compare would wrongly rank "0.10.0" below "0.2.0". */ +export function compareVersions(a: string, b: string): number { + const partsA = a.split(".").map(Number); + const partsB = b.split(".").map(Number); + const length = Math.max(partsA.length, partsB.length); + for (let i = 0; i < length; i += 1) { + const diff = (partsA[i] ?? 0) - (partsB[i] ?? 0); + if (diff !== 0) return Math.sign(diff); + } + return 0; +} + +/** "dev-main" (a local/non-release build) never numerically compares — any real release counts as newer. */ +export function isUpdateAvailable(current: string, latest: string): boolean { + if (current === "dev-main") return true; + return compareVersions(latest, current) > 0; +} + +interface GitHubRelease { + tag_name: string; +} + +/** Strips the release tag's leading "v" (tags are "v0.2.0", versions are "0.2.0") — GitHub's REST API, + * not `releases/latest` redirect-following, so a 404 (no releases yet) surfaces as a real thrown error + * instead of silently resolving to some unrelated page. `signal` lets the background auto-check (unlike an + * explicit `check-update`/`update` run, which should just wait) give up quickly on a slow network instead + * of noticeably delaying an unrelated command. */ +export async function getLatestReleaseVersion(signal?: AbortSignal): Promise { + const response = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, { + headers: { Accept: "application/vnd.github+json" }, + signal, + }); + if (!response.ok) { + throw new Error(`Failed to check latest release (${response.status} ${response.statusText})`); + } + const release = (await response.json()) as GitHubRelease; + return release.tag_name.replace(/^v/, ""); +} + +interface UpdateCheckCache { + lastCheckedAt: number; + latestVersion: string; +} + +/** + * Rate-limited (24h) background-style check — never throws, so it's safe to call unconditionally at the + * end of any command without risking that command's own output/exit code. Returns null on any failure + * (network down, GitHub unreachable, cache unreadable) or when running a dev-main build, so callers can + * just skip printing anything rather than needing their own error handling. + */ +export async function checkForUpdateCached(currentVersion: string): Promise<{ latestVersion: string; updateAvailable: boolean } | null> { + if (currentVersion === "dev-main") return null; + + try { + const cachePath = updateCheckCachePath(); + let cached: UpdateCheckCache | null = null; + try { + cached = JSON.parse(await readFile(cachePath, "utf8")) as UpdateCheckCache; + } catch { + // no cache yet, or unreadable — fall through to a fresh check + } + + let latestVersion: string; + if (cached && Date.now() - cached.lastCheckedAt < UPDATE_CHECK_CACHE_TTL_MS) { + latestVersion = cached.latestVersion; + } else { + latestVersion = await getLatestReleaseVersion(AbortSignal.timeout(BACKGROUND_CHECK_TIMEOUT_MS)); + await mkdir(join(homedir(), ".sessionforge"), { recursive: true }); + await writeFile(cachePath, JSON.stringify({ lastCheckedAt: Date.now(), latestVersion } satisfies UpdateCheckCache)); + } + + return { latestVersion, updateAvailable: isUpdateAvailable(currentVersion, latestVersion) }; + } catch { + return null; + } +} + +/** Downloads the platform-matched binary asset for a given release version — same naming convention + * install.sh/build-binary.mjs use: `sessionforge-`, `.exe` suffixed on Windows. */ +export async function downloadCliBinary(version: string, destPath: string): Promise { + const isWindows = platform() === "win32"; + const assetName = `sessionforge-${targetTriple()}${isWindows ? ".exe" : ""}`; + const url = `https://github.com/${REPO}/releases/download/v${version}/${assetName}`; + const response = await fetch(url); + if (!response.ok || !response.body) { + throw new Error(`Download failed (${response.status} ${response.statusText}): ${url}`); + } + await pipeline(Readable.fromWeb(response.body as import("node:stream/web").ReadableStream), createWriteStream(destPath)); + if (!isWindows) chmodSync(destPath, 0o755); +} + +/** + * Replaces the currently-running binary in place with a freshly-downloaded one — the standard + * self-updating-CLI pattern (same one rustup/many Go tools use), because a running executable can't just + * be overwritten directly on every OS: + * - Linux/macOS: renaming a file that's currently executing is fine — the OS keeps the old inode alive for + * the still-running process, and the path just starts pointing at the new file. A same-directory rename + * (not a cross-filesystem copy) keeps this atomic. + * - Windows: a running .exe can't be deleted or overwritten, but it CAN be renamed. So the running binary + * is renamed to a `.old.exe` sibling first, then the downloaded file takes its original name. The + * `.old.exe` is best-effort deleted immediately after (already-renamed-away, so this rarely fails, but a + * file still flushing to disk on a slow machine could keep it locked a moment longer) — a leftover + * `.old.exe` is harmless clutter, not a correctness problem, so a failed cleanup isn't treated as fatal. + */ +export async function selfReplaceBinary(newBinaryPath: string, currentExecPath: string): Promise { + if (platform() === "win32") { + const oldPath = `${currentExecPath}.old.exe`; + await rm(oldPath, { force: true }); + await rename(currentExecPath, oldPath); + await rename(newBinaryPath, currentExecPath); + await rm(oldPath, { force: true }).catch(() => {}); + return; + } + + await rename(newBinaryPath, currentExecPath); +} diff --git a/scripts/package-plugin.mjs b/scripts/package-plugin.mjs index ea08208..263f4de 100644 --- a/scripts/package-plugin.mjs +++ b/scripts/package-plugin.mjs @@ -9,11 +9,16 @@ // `@aadaa88/sessionforge` (an npm workspace symlink in this monorepo, not a real directory). Instead this // script bakes a real, non-symlinked copy of that package's built output into node_modules — something // that works when extracted standalone on a completely different machine, unlike a symlink. -import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs"; +import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { execFileSync } from "node:child_process"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +// Matches the same "dev-main" sentinel packages/cli/scripts/build-binary.mjs falls back to when built +// outside release.yml — kept as a plain string literal (not imported) since this script runs standalone, +// before the TS in packages/cli is necessarily even built yet. +const PLUGIN_VERSION_FILE = ".sessionforge-version"; + const repoRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); const buildDir = join(repoRoot, "build"); const stageDir = join(buildDir, "paseo-plugin"); @@ -45,6 +50,11 @@ function main() { cpSync(join(repoRoot, "packages", "cli", "package.json"), join(cliPackageDest, "package.json")); cpSync(join(repoRoot, "packages", "cli", "LICENSE"), join(cliPackageDest, "LICENSE")); + // Lets `sessionforge paseo-status` report drift between what's actually installed and the running CLI's + // own version — release.yml passes the real tag via this env var; unset (a local/manual package:plugin + // run) falls back to the same "dev-main" sentinel the CLI binary itself uses. + writeFileSync(join(stageDir, PLUGIN_VERSION_FILE), `${process.env.SESSIONFORGE_VERSION ?? "dev-main"}\n`); + rmSync(archivePath, { force: true }); // tar ships on Linux/macOS by default and as bsdtar on Windows 10 1803+ / Windows 11 — same assumption // install.sh/install.ps1 and this project's other platform-support claims already make. From 46b3b1bda5ae9c902290336c7e2e0a0fed293899 Mon Sep 17 00:00:00 2001 From: aMgLn Date: Wed, 2 Sep 2026 17:16:43 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=EC=9C=88=EB=8F=84=EC=9A=B0=20CI=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=88=98=EC=A0=95:=20=EA=B0=80=EC=A7=9C?= =?UTF-8?q?=20process.platform=EC=9D=B4=20=EC=8B=A4=EC=A0=9C=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EC=8B=9C=EC=8A=A4=ED=85=9C=EA=B9=8C=EC=A7=80=20?= =?UTF-8?q?=EC=86=8D=EC=9D=B4=EC=A7=80=20=EB=AA=BB=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - downloadCliBinary 테스트: process.platform을 "linux"로 흉내내도 chmodSync의 실행 비트는 실제 호스트가 진짜 Linux/macOS일 때만 의미가 있음 — Windows NTFS에는 그런 개념이 없어 실행 비트 검증만 실제 POSIX일 때로 분리 (activity.server.test.ts의 실제 /proc 테스트와 동일한 원칙) - checkForUpdateCached 테스트: process.env.HOME만 오버라이드하고 USERPROFILE은 빼먹어서, 실제 윈도우에서는 os.homedir()이 진짜 사용자 홈 디렉터리를 가리켜 캐시 파일이 샌드박스 밖으로 새어나가 테스트 간 오염이 발생했음 — 이미 이번 세션에서 trash.server.test.ts에 적용했던 것과 완전히 같은 종류의 버그를 새 테스트 파일에 또 반복한 것이라 동일하게 HOME과 USERPROFILE을 함께 오버라이드하도록 수정 - 로컬에서 typecheck 통과, 테스트 122개 전부 통과 확인 --- packages/cli/src/core/update.server.test.ts | 29 +++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/core/update.server.test.ts b/packages/cli/src/core/update.server.test.ts index ad21c1d..ffb37cb 100644 --- a/packages/cli/src/core/update.server.test.ts +++ b/packages/cli/src/core/update.server.test.ts @@ -101,6 +101,7 @@ describe("getLatestReleaseVersion", () => { }); describe("downloadCliBinary", () => { + const originalPlatform = process.platform; let root: string; beforeEach(async () => { @@ -108,11 +109,12 @@ describe("downloadCliBinary", () => { }); afterEach(async () => { + setPlatform(originalPlatform); await rm(root, { recursive: true, force: true }); vi.unstubAllGlobals(); }); - it("downloads the platform-matched asset and makes it executable on non-Windows", async () => { + it("downloads the platform-matched asset and writes it to the destination path", async () => { setPlatform("linux"); Object.defineProperty(process, "arch", { value: "x64", configurable: true }); @@ -126,7 +128,23 @@ describe("downloadCliBinary", () => { await downloadCliBinary("0.3.0", destPath); expect(await readFile(destPath, "utf8")).toBe("fake binary contents"); - expect(statSync(destPath).mode & 0o111).not.toBe(0); // executable bit set + }); + + // Faking process.platform doesn't fake the underlying filesystem — chmod's exec bits only mean anything + // real on a real POSIX filesystem, so this only runs where the actual OS is Linux/macOS, not merely + // where process.platform is set to one (same principle as activity.server.test.ts's real /proc test). + it.runIf(originalPlatform !== "win32")("sets the executable bit on a real POSIX filesystem", async () => { + // No process.platform faking here — this needs the real host OS's real filesystem semantics. + Object.defineProperty(process, "arch", { value: "x64", configurable: true }); + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("fake binary contents", { status: 200 })), + ); + + const destPath = join(root, "sessionforge"); + await downloadCliBinary("0.3.0", destPath); + + expect(statSync(destPath).mode & 0o111).not.toBe(0); }); it("throws with the status when the download fails", async () => { @@ -182,16 +200,23 @@ describe("selfReplaceBinary", () => { describe("checkForUpdateCached", () => { let root: string; let previousHome: string | undefined; + let previousUserProfile: string | undefined; beforeEach(async () => { root = await mkdtempAsync(join(tmpdir(), "sessionforge-update-cache-")); previousHome = process.env.HOME; + previousUserProfile = process.env.USERPROFILE; + // node:os's homedir() reads USERPROFILE (not HOME) on Windows — setting both keeps the cache file + // sandboxed to `root` regardless of which real OS runs this test. process.env.HOME = root; + process.env.USERPROFILE = root; }); afterEach(async () => { if (previousHome === undefined) delete process.env.HOME; else process.env.HOME = previousHome; + if (previousUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = previousUserProfile; await rm(root, { recursive: true, force: true }); vi.unstubAllGlobals(); });