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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 플러그인이 유일한 배포 경로입니다.

## 문서
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 26 additions & 1 deletion docs/MANUAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -25,7 +26,9 @@ sessionforge archive <id> [--reason ...] # move a session out of the active vi
sessionforge restore <id> [--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`,
Expand Down Expand Up @@ -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`,
Expand Down
4 changes: 4 additions & 0 deletions docs/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,16 @@ sessionforge cleanup --apply # move current JUNK candidates to trash (stil
sessionforge archive <session-id> --reason "done"
sessionforge restore <session-id>
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.:
Expand Down
107 changes: 96 additions & 11 deletions packages/cli/src/cli/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
DEFAULT_PLUGIN_ID,
downloadPluginArchive,
extractPluginArchive,
getInstalledPluginVersion,
getPluginStatus,
installPluginDirectory,
isPaseoCliAvailable,
Expand All @@ -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()];
Expand Down Expand Up @@ -308,6 +310,64 @@ async function cmdPaseoStatus(): Promise<void> {
}
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<void> {
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<void> {
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" : ""}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Create update files without following temp symlinks

On a shared temporary directory, another local user can pre-create the predictable sessionforge-update-<latest> pathname as a symlink to a file they control before the victim runs update. downloadCliBinary follows that symlink when writing, and selfReplaceBinary then renames the symlink into the victim's executable path; the attacker can subsequently replace its target so the victim executes attacker-controlled code. Use a private, uniquely created staging file (or an exclusive file in the executable's directory) rather than this predictable tmpdir() name.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Stage the replacement on the binary's filesystem

The updater downloads into tmpdir(), but selfReplaceBinary uses rename to install it. When /tmp is a separate filesystem from the installed binary (for example, the documented ~/.local/bin/sessionforge on Linux systems with a tmpfs /tmp), that rename fails with EXDEV, so every update leaves the old binary installed. Stage the download in the executable's directory, or copy it there before the final same-filesystem rename.

Useful? React with 👍 / 👎.

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 {
Expand All @@ -323,22 +383,19 @@ Usage:
sessionforge restore <id> [--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<void> {
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<void> {
switch (command) {
case "discover":
return cmdDiscover();
Expand All @@ -360,6 +417,10 @@ async function main(): Promise<void> {
return cmdWirePaseo(args);
case "paseo-status":
return cmdPaseoStatus();
case "check-update":
return cmdCheckUpdate();
case "update":
return cmdUpdate();
case "version":
case "-v":
console.log(getVersion());
Expand All @@ -376,6 +437,30 @@ async function main(): Promise<void> {
}
}

async function main(): Promise<void> {
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.)`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep update notices out of JSON stdout

When a released CLI is behind and a user invokes any JSON-producing command such as list --json, show --json, search --json, cleanup --json, or audit --json, this appends a human-readable update notice to stdout after the JSON document. That makes the documented structured output unparsable for scripts precisely when an update is available; suppress the notice for --json or send it to stderr.

Useful? React with 👍 / 👎.

}
}
}

main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
Expand Down
14 changes: 14 additions & 0 deletions packages/cli/src/core/paseo-wire.server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const {
arePluginsEnabled,
downloadPluginArchive,
extractPluginArchive,
getInstalledPluginVersion,
getPluginStatus,
installPluginDirectory,
isPaseoCliAvailable,
Expand Down Expand Up @@ -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();
});
});
});
17 changes: 17 additions & 0 deletions packages/cli/src/core/paseo-wire.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,20 @@ export async function getPluginStatus(id: string = DEFAULT_PLUGIN_ID): Promise<P
export function pluginArchiveExists(path: string): boolean {
return existsSync(path);
}

const PLUGIN_VERSION_FILE = ".sessionforge-version";

/**
* Reads the version marker `scripts/package-plugin.mjs` bakes into every packaged plugin bundle — lets
* `paseo-status` report drift between what's actually installed and the running CLI's own version, without
* needing Paseo itself to know anything about SessionForge's versioning. Returns null for a plugin that
* was never installed via `wire-paseo` at all (e.g. the manual `paseo plugin install /path/to/clone` dev
* flow, which has no such marker file) — that's a legitimate, expected case, not an error.
*/
export async function getInstalledPluginVersion(pluginDir: string): Promise<string | null> {
try {
return (await readFile(join(pluginDir, PLUGIN_VERSION_FILE), "utf8")).trim();
} catch {
return null;
}
}
Loading
Loading