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..ffb37cb --- /dev/null +++ b/packages/cli/src/core/update.server.test.ts @@ -0,0 +1,261 @@ +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", () => { + const originalPlatform = process.platform; + let root: string; + + beforeEach(async () => { + root = await mkdtempAsync(join(tmpdir(), "sessionforge-update-dl-")); + }); + + afterEach(async () => { + setPlatform(originalPlatform); + await rm(root, { recursive: true, force: true }); + vi.unstubAllGlobals(); + }); + + it("downloads the platform-matched asset and writes it to the destination path", 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"); + }); + + // 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 () => { + 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; + 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(); + }); + + 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.