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
67 changes: 44 additions & 23 deletions packages/cli/src/cli/bin.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#!/usr/bin/env -S npx tsx
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { AiderAdapter } from "../core/aider-adapter.server.js";
Expand Down Expand Up @@ -82,6 +83,22 @@ function printJson(value: unknown): void {
console.log(JSON.stringify(value, null, 2));
}

/**
* Runs `fn` with a fresh, uniquely-named scratch directory, cleaned up afterward either way. Used for
* downloads that get replaced/installed into their real destination right after — a fixed, predictable
* filename directly under the shared OS temp dir would let another local user pre-place a symlink there
* and have the download silently overwrite whatever it points at (no O_EXCL); `mkdtemp`'s random suffix
* means there's nothing for an attacker to predict and pre-create in advance.
*/
async function withScratchDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
const dir = await mkdtemp(join(tmpdir(), "sessionforge-"));
try {
return await fn(dir);
} finally {
await rm(dir, { recursive: true, force: true });
}
}

async function cmdDiscover(): Promise<void> {
const store = new SessionStore();
try {
Expand Down Expand Up @@ -279,27 +296,29 @@ async function cmdWirePaseo(args: ParsedArgs): Promise<void> {
return;
}

const archivePath = join(tmpdir(), PLUGIN_ARCHIVE_NAME);
console.log(`Downloading the v${version} Paseo plugin release asset...`);
await downloadPluginArchive(version, archivePath);
await withScratchDir(async (scratchDir) => {
const archivePath = join(scratchDir, PLUGIN_ARCHIVE_NAME);
console.log(`Downloading the v${version} Paseo plugin release asset...`);
await downloadPluginArchive(version, archivePath);

const installDir = pluginInstallDir();
console.log(`Extracting to ${installDir}...`);
await extractPluginArchive(archivePath, installDir);
const installDir = pluginInstallDir();
console.log(`Extracting to ${installDir}...`);
await extractPluginArchive(archivePath, installDir);

const id = flagString(args.flags, "id") ?? DEFAULT_PLUGIN_ID;
console.log("Installing via `paseo plugin install`...");
const result = await installPluginDirectory(installDir, id);
const id = flagString(args.flags, "id") ?? DEFAULT_PLUGIN_ID;
console.log("Installing via `paseo plugin install`...");
const result = await installPluginDirectory(installDir, id);

if (result.status !== "running") {
console.error(`\nPlugin installed but is not running (status: ${result.status}).`);
if (result.error) console.error(result.error);
console.error(`Check \`paseo plugin logs ${id}\` for details.`);
process.exitCode = 1;
return;
}
if (result.status !== "running") {
console.error(`\nPlugin installed but is not running (status: ${result.status}).`);
if (result.error) console.error(result.error);
console.error(`Check \`paseo plugin logs ${id}\` for details.`);
process.exitCode = 1;
return;
}

console.log(`\nSessionForge is wired into Paseo (plugin id: ${id}, status: running).`);
console.log(`\nSessionForge is wired into Paseo (plugin id: ${id}, status: running).`);
});
}

async function cmdPaseoStatus(): Promise<void> {
Expand Down Expand Up @@ -360,12 +379,14 @@ async function cmdUpdate(): Promise<void> {
}

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);
await withScratchDir(async (scratchDir) => {
const newBinaryPath = join(scratchDir, `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.`);
}
Expand Down
70 changes: 70 additions & 0 deletions packages/cli/src/core/download.server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { mkdtemp, 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 { downloadFile } from "./download.server.js";

describe("downloadFile", () => {
let root: string;

beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "sessionforge-download-"));
});

afterEach(async () => {
await rm(root, { recursive: true, force: true });
vi.unstubAllGlobals();
});

it("writes the response body to the destination path", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("hello world", { status: 200 })),
);

const destPath = join(root, "file.bin");
await downloadFile("https://example.com/file.bin", destPath);

expect(await readFile(destPath, "utf8")).toBe("hello world");
});

it("throws with the status when the request itself fails", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(null, { status: 404, statusText: "Not Found" })),
);

await expect(downloadFile("https://example.com/file.bin", join(root, "file.bin"))).rejects.toThrow(/404/);
});

it("succeeds silently when the server sends no Content-Length to check against", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("no length header on this one", { status: 200 })),
);

await expect(downloadFile("https://example.com/file.bin", join(root, "file.bin"))).resolves.toBeUndefined();
});

it("throws when fewer bytes arrive than the server's own Content-Length promised — a truncated transfer", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("short", { status: 200, headers: { "content-length": "9999" } })),
);

await expect(downloadFile("https://example.com/file.bin", join(root, "file.bin"))).rejects.toThrow(/incomplete/i);
});

it("succeeds when the transferred bytes match a real Content-Length", async () => {
const body = "exact content";
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(body, { status: 200, headers: { "content-length": String(Buffer.byteLength(body)) } })),
);

const destPath = join(root, "file.bin");
await downloadFile("https://example.com/file.bin", destPath);

expect(await readFile(destPath, "utf8")).toBe(body);
});
});
33 changes: 33 additions & 0 deletions packages/cli/src/core/download.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { createWriteStream } from "node:fs";
import { pipeline } from "node:stream/promises";
import { Readable, Transform } from "node:stream";

/**
* Fetches a URL and streams it to disk, verifying the transferred byte count against the response's own
* Content-Length (when the server sends one) before treating the write as successful — a connection reset
* or truncated proxy response can otherwise leave a corrupt, incomplete file on disk that still looks like
* a normal successful download to a caller that only checked `response.ok`. Shared by
* `paseo-wire.server.ts` (plugin archive) and `update.server.ts` (CLI binary) since both hand their result
* straight to something high-stakes: `paseo plugin install` or replacing the running executable.
*/
export async function downloadFile(url: string, destPath: string): Promise<void> {
const response = await fetch(url);
if (!response.ok || !response.body) {
throw new Error(`Download failed (${response.status} ${response.statusText}): ${url}`);
}

const expectedLength = response.headers.get("content-length");
let receivedBytes = 0;
const countBytes = new Transform({
transform(chunk: Buffer, _encoding, callback) {
receivedBytes += chunk.length;
callback(null, chunk);
},
});

await pipeline(Readable.fromWeb(response.body as import("node:stream/web").ReadableStream), countBytes, createWriteStream(destPath));

if (expectedLength !== null && receivedBytes !== Number(expectedLength)) {
throw new Error(`Download incomplete: expected ${expectedLength} bytes, got ${receivedBytes} (${url})`);
}
}
39 changes: 34 additions & 5 deletions packages/cli/src/core/paseo-wire.server.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { existsSync, writeFileSync } from "node:fs";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
Expand Down Expand Up @@ -94,7 +95,6 @@ describe("paseo-wire", () => {
await downloadPluginArchive("1.2.3", destPath);

expect(fetchMock).toHaveBeenCalledOnce();
const { readFile } = await import("node:fs/promises");
expect(await readFile(destPath, "utf8")).toBe("fake tarball contents");
});

Expand All @@ -109,7 +109,7 @@ describe("paseo-wire", () => {
});

describe("extractPluginArchive", () => {
it("wipes the destination and shells out to tar", async () => {
it("extracts into a staging directory alongside the destination, not straight into it", async () => {
const destDir = join(root, "dest");
let sawTarArgs: string[] = [];
execFileHandler = (cmd, args, callback) => {
Expand All @@ -120,7 +120,37 @@ describe("paseo-wire", () => {

await extractPluginArchive(join(root, "archive.tar.gz"), destDir);

expect(sawTarArgs).toEqual(["-xzf", join(root, "archive.tar.gz"), "-C", destDir]);
expect(sawTarArgs).toEqual(["-xzf", join(root, "archive.tar.gz"), "-C", `${destDir}.staging`]);
});

it("leaves a previous working install untouched when tar fails, instead of wiping it first", async () => {
const destDir = join(root, "dest");
await mkdir(destDir, { recursive: true });
await writeFile(join(destDir, "still-here.txt"), "previous working install");

execFileHandler = (_cmd, _args, callback) => callback(new Error("tar: unexpected end of file"));

await expect(extractPluginArchive(join(root, "archive.tar.gz"), destDir)).rejects.toThrow();

expect(await readFile(join(destDir, "still-here.txt"), "utf8")).toBe("previous working install");
});

it("replaces the destination with the newly-extracted content once tar actually succeeds", async () => {
const destDir = join(root, "dest");
await mkdir(destDir, { recursive: true });
await writeFile(join(destDir, "old.txt"), "old content");

execFileHandler = (_cmd, args, callback) => {
// Simulate tar really writing into the staging directory it was told to extract into.
const stagingDir = args[args.indexOf("-C") + 1];
writeFileSync(join(stagingDir, "new.txt"), "new content");
callback(null, { stdout: "", stderr: "" });
};

await extractPluginArchive(join(root, "archive.tar.gz"), destDir);

expect(existsSync(join(destDir, "old.txt"))).toBe(false);
expect(await readFile(join(destDir, "new.txt"), "utf8")).toBe("new content");
});
});

Expand Down Expand Up @@ -168,7 +198,6 @@ describe("paseo-wire", () => {

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");
Expand Down
35 changes: 18 additions & 17 deletions packages/cli/src/core/paseo-wire.server.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import { execFile } from "node:child_process";
import { createWriteStream, existsSync } from "node:fs";
import { mkdir, readFile, rm } from "node:fs/promises";
import { mkdir, readFile, rename, rm } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import { pipeline } from "node:stream/promises";
import { Readable } from "node:stream";
import { promisify } from "node:util";
import { downloadFile } from "./download.server.js";

const execFileAsync = promisify(execFile);

Expand Down Expand Up @@ -57,19 +55,26 @@ export async function arePluginsEnabled(): Promise<boolean> {
* always wires up the plugin build it actually shipped with, not a possibly-incompatible newer one. */
export async function downloadPluginArchive(version: string, destPath: string): Promise<void> {
const url = `https://github.com/4mGLn/sessionforge/releases/download/v${version}/${PLUGIN_ARCHIVE_NAME}`;
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));
await downloadFile(url, destPath);
}

/** tar ships on Linux/macOS by default and as bsdtar on Windows 10 1803+ / Windows 11 — the same
* assumption install.sh/install.ps1 and this project's other platform-support claims already make. */
/**
* tar ships on Linux/macOS by default and as bsdtar on Windows 10 1803+ / Windows 11 — the same assumption
* install.sh/install.ps1 and this project's other platform-support claims already make.
*
* Extracts to a staging directory first and only replaces `destDir` after `tar` has actually succeeded —
* a corrupt or truncated archive (bad download, disk full mid-extract) then just fails cleanly, instead of
* first wiping out a previously-working plugin install and leaving Paseo pointed at a broken directory.
*/
export async function extractPluginArchive(archivePath: string, destDir: string): Promise<void> {
const stagingDir = `${destDir}.staging`;
await rm(stagingDir, { recursive: true, force: true });
await mkdir(stagingDir, { recursive: true });
await execFileAsync("tar", ["-xzf", archivePath, "-C", stagingDir]);

// Only reached once tar has proven the archive is valid.
await rm(destDir, { recursive: true, force: true });
await mkdir(destDir, { recursive: true });
await execFileAsync("tar", ["-xzf", archivePath, "-C", destDir]);
await rename(stagingDir, destDir);
}

interface PluginInstallResult {
Expand All @@ -91,10 +96,6 @@ export async function getPluginStatus(id: string = DEFAULT_PLUGIN_ID): Promise<P
return plugins.find((plugin) => plugin.id === id) ?? null;
}

export function pluginArchiveExists(path: string): boolean {
return existsSync(path);
}

const PLUGIN_VERSION_FILE = ".sessionforge-version";

/**
Expand Down
Loading
Loading