From bf7c2bb600e4343528d3b3eca89e5174e6f56d7c Mon Sep 17 00:00:00 2001 From: aMgLn Date: Wed, 2 Sep 2026 14:39:42 +0900 Subject: [PATCH] =?UTF-8?q?sessionforge=20wire-paseo=20=EB=AA=85=EB=A0=B9?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20=E2=80=94=20Paseo=20=ED=94=8C=EB=9F=AC?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=9E=90=EB=8F=99=20=EC=84=A4=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - release.yml에 package-plugin 잡 추가: Paseo 플러그인(index.ts, main.client.tsx, src/server/*)을 독립 실행 가능한 디렉터리로 패키징해 릴리스에 함께 첨부 (scripts/package-plugin.mjs) - 실제로 확인해봄: paseo plugin install owner/repo 형태의 git 소스 설치는 이 저장소의 npm 워크스페이스 의존성(@aadaa88/sessionforge)을 해석하지 못해 빌드 자체가 실패함 — 그래서 npm 워크스페이스 심링크 대신 빌드된 @aadaa88/sessionforge를 node_modules에 실제로 복사해 넣은 자기 완결형 패키지를 만듦 (실제로 paseo plugin install로 설치·구동까지 검증함) - 새 CLI 명령 sessionforge wire-paseo: paseo CLI 존재 확인 → 데몬의 pluginsEnabled 확인(비활성화면 절대 직접 켜지 않고 안내만 하고 중단) → 현재 CLI 버전과 일치하는 릴리스 자산 다운로드(releases/latest 아님) → 압축 해제 → paseo plugin install로 설치 → 실행 상태 검증 - sessionforge paseo-status: 설치/실행 상태 조회 - build-binary.mjs에서 esbuild define으로 버전을 바이너리에 주입(런타임에 package.json을 읽을 수 없으므로) — 실제 컴파일된 바이너리로 wire-paseo를 실행해 버전 문자열과 다운로드 URL이 올바른지 확인함 - README.md, README.ko.md, docs/MANUAL.md, docs/REFERENCE.md, CONTRIBUTING.md 문서 갱신, 테스트 12개 추가(전체 97개 통과) --- .github/workflows/release.yml | 24 ++- CONTRIBUTING.md | 12 +- README.ko.md | 12 +- README.md | 12 +- docs/MANUAL.md | 19 ++ docs/REFERENCE.md | 8 +- package.json | 3 +- packages/cli/scripts/build-binary.mjs | 8 +- packages/cli/src/cli/bin.ts | 93 ++++++++++ .../cli/src/core/paseo-wire.server.test.ts | 167 ++++++++++++++++++ packages/cli/src/core/paseo-wire.server.ts | 96 ++++++++++ scripts/package-plugin.mjs | 56 ++++++ 12 files changed, 497 insertions(+), 13 deletions(-) create mode 100644 packages/cli/src/core/paseo-wire.server.test.ts create mode 100644 packages/cli/src/core/paseo-wire.server.ts create mode 100644 scripts/package-plugin.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 840698d..bde2ad2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,8 +50,30 @@ jobs: path: packages/cli/build/sessionforge-${{ matrix.target }}* if-no-files-found: error + # The Paseo plugin (this repo's root — index.ts, main.client.tsx, src/server/*) isn't OS/arch-specific + # 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: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + + - run: npm install + - run: npm run package:plugin + + - uses: actions/upload-artifact@v4 + with: + name: sessionforge-paseo-plugin + path: build/sessionforge-paseo-plugin.tar.gz + if-no-files-found: error + release: - needs: build-binaries + needs: [build-binaries, package-plugin] runs-on: ubuntu-latest permissions: contents: write diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e9e1494..954df9a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,9 +51,15 @@ with nvm, not by reading release notes, so re-verify empirically before ever low `.github/workflows/release.yml` is manual-trigger only (`workflow_dispatch` from the Actions tab) — it never runs on a push. Builds a standalone `sessionforge` binary (Node SEA) natively per platform — Linux, -macOS Intel, macOS Apple Silicon, Windows — and attaches them to a new GitHub Release tagged from -`packages/cli/package.json`'s current version. This is the only distribution channel; there is no npm -publish step. +macOS Intel, macOS Apple Silicon, Windows — and also packages the Paseo plugin (`scripts/package-plugin.mjs` +— a self-contained directory with a real, non-symlinked copy of the built `@aadaa88/sessionforge` package +baked into `node_modules`, since npm workspace symlinks don't survive being extracted on someone else's +machine; verified by actually installing the packaged output via `paseo plugin install`, not just built). +All of these are attached to a new GitHub Release tagged from `packages/cli/package.json`'s current +version. This is the only distribution channel; there is no npm publish step. `sessionforge wire-paseo` +downloads that same plugin asset and installs it — keep its version-matched download URL (see +`packages/cli/src/core/paseo-wire.server.ts`) in sync with this workflow's release tag format if either +changes. ## Making changes diff --git a/README.ko.md b/README.ko.md index f7bdcd6..d5d58a9 100644 --- a/README.ko.md +++ b/README.ko.md @@ -45,9 +45,15 @@ irm https://raw.githubusercontent.com/4mGLn/sessionforge/main/install.ps1 | iex `sessionforge` 바이너리를 내려받아 PATH에 등록합니다. 지원 대상과 설치 경로를 바꾸는 방법은 [`docs/MANUAL.md`](docs/MANUAL.md#installing-the-standalone-binary)를 참고하세요. -**Paseo 플러그인**으로도 사용할 수 있습니다: 클론한 뒤 `paseo plugin install /path/to/sessionforge` — -자세한 내용과 알려진 환경 이슈는 [`docs/MANUAL.md`](docs/MANUAL.md#installing-the-paseo-plugin) 참고 -(이 경로는 Node.js 22.16 이상이 필요합니다; 독립 바이너리는 Node.js가 전혀 필요 없습니다). +**Paseo 플러그인**으로도 사용할 수 있습니다 — 위 바이너리가 있다면 다음으로 연결하세요: + +```bash +sessionforge wire-paseo +``` + +자세한 내용, 알려진 환경 이슈, 그리고 (플러그인 개발 시에만 필요한) 수동 설치 방법 +`paseo plugin install /path/to/sessionforge`(저장소 클론과 Node.js 22.16 이상 필요)는 +[`docs/MANUAL.md`](docs/MANUAL.md#installing-the-paseo-plugin)를 참고하세요. npm에는 배포하지 않습니다 — 독립 바이너리와 Paseo 플러그인이 유일한 배포 경로입니다. diff --git a/README.md b/README.md index 223a78b..d0e4f11 100644 --- a/README.md +++ b/README.md @@ -45,9 +45,15 @@ Downloads a prebuilt `sessionforge` binary for your platform from the latest [GitHub Release](https://github.com/4mGLn/sessionforge/releases) and puts it on your PATH. See [`docs/MANUAL.md`](docs/MANUAL.md#installing-the-standalone-binary) for supported targets and override options. -Also available as a **Paseo plugin**: `paseo plugin install /path/to/sessionforge` after cloning — see -[`docs/MANUAL.md`](docs/MANUAL.md#installing-the-paseo-plugin) for details and a known environment quirk -(this route needs Node.js 22.16+; the standalone binary needs no Node.js at all). +Also available as a **Paseo plugin** — once you have the binary above, wire it up with: + +```bash +sessionforge wire-paseo +``` + +See [`docs/MANUAL.md`](docs/MANUAL.md#installing-the-paseo-plugin) for details, a known environment quirk, +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`). Not published to npm — the standalone binary and the Paseo plugin are the only distribution channels. diff --git a/docs/MANUAL.md b/docs/MANUAL.md index be4e723..44ec47f 100644 --- a/docs/MANUAL.md +++ b/docs/MANUAL.md @@ -24,6 +24,8 @@ sessionforge cleanup [--apply] [--json] # preview (default) or apply junk cle sessionforge archive [--reason ...] # move a session out of the active view 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 ``` `list` flags: `--agent`, `--project`, `--status`, `--lifecycle`, `--category`, `--older-than 30d`, @@ -84,6 +86,23 @@ no default whole-filesystem scan. ## Installing the Paseo plugin +The easy way, if you already have the standalone `sessionforge` binary or CLI ([Install](#installing-the-standalone-binary)): + +```bash +sessionforge wire-paseo # downloads and installs the version-matched plugin, via `paseo plugin install` +sessionforge paseo-status # check whether it's installed and running +``` + +This automates exactly the manual flow below: it downloads the Paseo-plugin release asset matching this +CLI's own version (not `releases/latest` — an older CLI always wires up the plugin build it actually +shipped with), extracts it to `~/.sessionforge/paseo-plugin`, and runs `paseo plugin install` against that +directory. It never touches the daemon's `pluginsEnabled` switch itself — plugins are trusted, unsandboxed +code, so if plugins aren't enabled on your daemon, `wire-paseo` stops and tells you to enable them yourself +first (Settings → Plugins → Enable plugins in the Paseo app). + +The manual way — needed if you're developing the plugin itself, since it points `paseo` straight at your +working tree instead of a downloaded release snapshot: + ```bash paseo plugin install /path/to/sessionforge paseo plugin reload sessionforge # after any source change — never auto-reloaded diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 76cbec1..ee7b6db 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -33,6 +33,8 @@ packages/cli/ @aadaa88/sessionforge — the standalone pack relationships.server.ts local heuristic DUPLICATE/SUPERSEDED cross-session detection (same workspace, topic word-overlap, timing) trash.server.ts OS-dispatched move-to-trash for delete (Linux XDG Trash / macOS ~/.Trash / Windows Recycle Bin) 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 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 @@ -54,8 +56,12 @@ main.client.tsx session browser UI (sidebar panel): search/filter/a checkboxes + bulk actions, per-provider logo icons, click-to-preview dialog with related sessions, per-session and per-provider file size +scripts/package-plugin.mjs packages the Paseo plugin (this repo's root — see above) into a self-contained + directory, then tars it — what `sessionforge wire-paseo` downloads and installs + .github/workflows/ CI (typecheck/test/build on Linux/macOS/Windows) + a manual-trigger release workflow - (builds standalone binaries per platform, attaches them to a GitHub Release) + (builds standalone binaries per platform, packages the Paseo plugin, attaches + both to a GitHub Release) ``` ## Why a plugin *and* a CLI — and why they're separate packages diff --git a/package.json b/package.json index fa0dcf3..f2129a7 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "build": "npm run build --workspace packages/cli", "test": "npm test --workspace packages/cli", "cli": "npm run cli --workspace packages/cli --", - "postinstall": "npm run build --workspace packages/cli" + "postinstall": "npm run build --workspace packages/cli", + "package:plugin": "node scripts/package-plugin.mjs" }, "dependencies": { "@aadaa88/sessionforge": "*" diff --git a/packages/cli/scripts/build-binary.mjs b/packages/cli/scripts/build-binary.mjs index 9df5857..3e17cd7 100644 --- a/packages/cli/scripts/build-binary.mjs +++ b/packages/cli/scripts/build-binary.mjs @@ -7,7 +7,7 @@ // — SEA binaries aren't cross-compilable from one machine — see .github/workflows/release.yml, which runs // this once per matrix OS to cover Linux, both macOS architectures, and Windows. import { execFileSync } from "node:child_process"; -import { chmodSync, copyFileSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, copyFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; import { arch, platform } from "node:os"; import { dirname, join } from "node:path"; @@ -51,6 +51,8 @@ async function main() { rmSync(buildDir, { recursive: true, force: true }); mkdirSync(buildDir, { recursive: true }); + const { version } = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")); + console.log(`Bundling CLI entry point for ${triple}...`); await esbuild.build({ entryPoints: [join(packageRoot, "src", "cli", "bin.ts")], @@ -63,6 +65,10 @@ async function main() { // platform:"node" already treats bare node builtins as external automatically, this just makes it // explicit for both the unprefixed and "node:"-prefixed spellings used across the source. external: ["node:sqlite"], + // Baked in at build time since the binary has no package.json on disk to read at runtime once + // distributed standalone — `wire-paseo` needs its own version to pick the matching GitHub Release + // asset. + define: { __SESSIONFORGE_VERSION__: JSON.stringify(version) }, logLevel: "info", }); diff --git a/packages/cli/src/cli/bin.ts b/packages/cli/src/cli/bin.ts index ce0694d..9088328 100755 --- a/packages/cli/src/cli/bin.ts +++ b/packages/cli/src/cli/bin.ts @@ -1,4 +1,7 @@ #!/usr/bin/env -S npx tsx +import { readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { AiderAdapter } from "../core/aider-adapter.server.js"; import { ClaudeCodeAdapter } from "../core/claude-adapter.server.js"; import { CodexAdapter } from "../core/codex-adapter.server.js"; @@ -6,6 +9,17 @@ import { runDiscovery } from "../core/discover.server.js"; import { GeminiCliAdapter } from "../core/gemini-adapter.server.js"; import { archiveSession, restoreSession, runCleanup, SessionNotFoundError } from "../core/lifecycle-actions.server.js"; import { OpenCodeAdapter } from "../core/opencode-adapter.server.js"; +import { + arePluginsEnabled, + DEFAULT_PLUGIN_ID, + downloadPluginArchive, + extractPluginArchive, + getPluginStatus, + installPluginDirectory, + isPaseoCliAvailable, + PLUGIN_ARCHIVE_NAME, + pluginInstallDir, +} 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 { formatSessionDetail, formatSessionTable, parseOlderThan } from "./format.js"; @@ -13,6 +27,17 @@ import { formatSessionDetail, formatSessionTable, parseOlderThan } from "./forma const ADAPTERS = [new ClaudeCodeAdapter(), new CodexAdapter(), new GeminiCliAdapter(), new OpenCodeAdapter(), new AiderAdapter()]; const ACTOR = "cli"; +// Baked in by esbuild's `define` when built into the standalone binary (see build-binary.mjs) — the +// binary has no package.json on disk at runtime to read its own version from otherwise. Falls back to +// reading packages/cli/package.json directly when running from raw source (tsx, no esbuild pass). +declare const __SESSIONFORGE_VERSION__: string | undefined; + +function getVersion(): string { + if (typeof __SESSIONFORGE_VERSION__ !== "undefined") return __SESSIONFORGE_VERSION__; + const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")) as { version: string }; + return pkg.version; +} + interface ParsedArgs { positional: string[]; flags: Map; @@ -212,6 +237,68 @@ async function cmdAudit(args: ParsedArgs): Promise { } } +/** + * Automates the manual "clone the repo, `paseo plugin install /path/to/it`" flow: downloads the + * version-matched Paseo plugin bundle from this CLI's own GitHub Release, extracts it to a stable local + * directory, and installs it via the real `paseo plugin install`. Never touches the daemon's + * `pluginsEnabled` switch itself — plugins are trusted, unsandboxed code, so enabling that is left to the + * user, in the Paseo app. + */ +async function cmdWirePaseo(args: ParsedArgs): Promise { + if (!(await isPaseoCliAvailable())) { + console.error("The `paseo` CLI was not found on PATH. Install Paseo first, then re-run this command."); + process.exitCode = 1; + return; + } + console.log("paseo CLI found."); + + if (!(await arePluginsEnabled())) { + console.error( + "\nPlugins are disabled on this Paseo daemon. Plugins are trusted, unsandboxed code — backend " + + "plugin code can access your daemon machine, including files, processes, credentials, and " + + "network services. Client plugin code runs inside the Paseo app.\n\n" + + "Enable plugins yourself first (Settings → Plugins → Enable plugins in the Paseo app), " + + "then re-run this command.", + ); + process.exitCode = 1; + return; + } + console.log("Plugins are enabled on this daemon."); + + const version = flagString(args.flags, "version") ?? getVersion(); + const archivePath = join(tmpdir(), 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 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; + } + + console.log(`\nSessionForge is wired into Paseo (plugin id: ${id}, status: running).`); +} + +async function cmdPaseoStatus(): Promise { + const status = await getPluginStatus(DEFAULT_PLUGIN_ID); + if (!status) { + console.log(`Not installed. Run \`sessionforge wire-paseo\` to install it.`); + return; + } + console.log(`${status.id}: ${status.status}${status.enabled ? "" : " (disabled)"} — ${status.path}`); + if (status.error) console.log(`Error: ${status.error}`); +} + function printHelp(): void { console.log(`sessionforge — unified agent session inventory (SessionForge) @@ -224,6 +311,8 @@ Usage: sessionforge archive [--reason ...] move a session out of the active view 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 `); } @@ -248,6 +337,10 @@ async function main(): Promise { return cmdRestore(args); case "audit": return cmdAudit(args); + case "wire-paseo": + return cmdWirePaseo(args); + case "paseo-status": + return cmdPaseoStatus(); case undefined: case "help": case "--help": diff --git a/packages/cli/src/core/paseo-wire.server.test.ts b/packages/cli/src/core/paseo-wire.server.test.ts new file mode 100644 index 0000000..13f1b28 --- /dev/null +++ b/packages/cli/src/core/paseo-wire.server.test.ts @@ -0,0 +1,167 @@ +import { mkdtemp, 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"; + +type ExecFileCallback = (error: Error | null, result?: { stdout: string; stderr: string }) => void; +type ExecFileHandler = (cmd: string, args: string[], callback: ExecFileCallback) => void; + +let execFileHandler: ExecFileHandler = (_cmd, _args, callback) => callback(null, { stdout: "", stderr: "" }); + +vi.mock("node:child_process", () => ({ + execFile: vi.fn((cmd: string, args: string[], callback: ExecFileCallback) => execFileHandler(cmd, args, callback)), +})); + +const { + arePluginsEnabled, + downloadPluginArchive, + extractPluginArchive, + getPluginStatus, + installPluginDirectory, + isPaseoCliAvailable, + pluginInstallDir, +} = await import("./paseo-wire.server.js"); + +function jsonResult(value: unknown): { stdout: string; stderr: string } { + return { stdout: JSON.stringify(value), stderr: "" }; +} + +describe("paseo-wire", () => { + let root: string; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "sessionforge-paseo-wire-")); + execFileHandler = (_cmd, _args, callback) => callback(null, { stdout: "", stderr: "" }); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + vi.clearAllMocks(); + vi.unstubAllGlobals(); + }); + + describe("isPaseoCliAvailable", () => { + it("returns true when `paseo --version` succeeds", async () => { + execFileHandler = (_cmd, _args, callback) => callback(null, { stdout: "0.7.0\n", stderr: "" }); + expect(await isPaseoCliAvailable()).toBe(true); + }); + + it("returns false when paseo isn't on PATH", async () => { + execFileHandler = (_cmd, _args, callback) => callback(new Error("command not found: paseo")); + expect(await isPaseoCliAvailable()).toBe(false); + }); + }); + + describe("arePluginsEnabled", () => { + it("reads pluginsEnabled from /config.json", async () => { + await writeFile(join(root, "config.json"), JSON.stringify({ pluginsEnabled: true })); + execFileHandler = (_cmd, args, callback) => { + if (args.includes("status")) return callback(null, jsonResult({ home: root })); + callback(new Error(`unexpected exec: ${args.join(" ")}`)); + }; + expect(await arePluginsEnabled()).toBe(true); + }); + + it("treats a missing pluginsEnabled field as disabled, matching Paseo's own default", async () => { + await writeFile(join(root, "config.json"), JSON.stringify({})); + execFileHandler = (_cmd, args, callback) => { + if (args.includes("status")) return callback(null, jsonResult({ home: root })); + callback(new Error(`unexpected exec: ${args.join(" ")}`)); + }; + expect(await arePluginsEnabled()).toBe(false); + }); + + it("treats pluginsEnabled: false as disabled", async () => { + await writeFile(join(root, "config.json"), JSON.stringify({ pluginsEnabled: false })); + execFileHandler = (_cmd, args, callback) => { + if (args.includes("status")) return callback(null, jsonResult({ home: root })); + callback(new Error(`unexpected exec: ${args.join(" ")}`)); + }; + expect(await arePluginsEnabled()).toBe(false); + }); + }); + + describe("downloadPluginArchive", () => { + it("downloads the version-matched release asset, not `releases/latest`", async () => { + const fetchMock = vi.fn(async (url: string) => { + expect(url).toBe("https://github.com/4mGLn/sessionforge/releases/download/v1.2.3/sessionforge-paseo-plugin.tar.gz"); + return new Response("fake tarball contents", { status: 200 }); + }); + vi.stubGlobal("fetch", fetchMock); + + const destPath = join(root, "plugin.tar.gz"); + 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"); + }); + + it("throws with the status when the download fails", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(null, { status: 404, statusText: "Not Found" })), + ); + + await expect(downloadPluginArchive("9.9.9", join(root, "plugin.tar.gz"))).rejects.toThrow(/404/); + }); + }); + + describe("extractPluginArchive", () => { + it("wipes the destination and shells out to tar", async () => { + const destDir = join(root, "dest"); + let sawTarArgs: string[] = []; + execFileHandler = (cmd, args, callback) => { + sawTarArgs = args; + expect(cmd).toBe("tar"); + callback(null, { stdout: "", stderr: "" }); + }; + + await extractPluginArchive(join(root, "archive.tar.gz"), destDir); + + expect(sawTarArgs).toEqual(["-xzf", join(root, "archive.tar.gz"), "-C", destDir]); + }); + }); + + describe("installPluginDirectory", () => { + it("passes the directory and id through to `paseo plugin install`", async () => { + let sawArgs: string[] = []; + execFileHandler = (_cmd, args, callback) => { + sawArgs = args; + callback(null, jsonResult({ id: "sessionforge", path: "/plugin/dir", enabled: true, status: "running" })); + }; + + const result = await installPluginDirectory("/plugin/dir", "sessionforge"); + + expect(sawArgs).toEqual(["plugin", "install", "/plugin/dir", "--id", "sessionforge", "--json"]); + expect(result).toEqual({ id: "sessionforge", path: "/plugin/dir", enabled: true, status: "running" }); + }); + }); + + describe("getPluginStatus", () => { + it("finds the plugin by id in `paseo plugin ls --json`'s output", async () => { + execFileHandler = (_cmd, _args, callback) => + callback( + null, + jsonResult([ + { id: "other-plugin", path: "/x", enabled: true, status: "running" }, + { id: "sessionforge", path: "/y", enabled: true, status: "running" }, + ]), + ); + + expect(await getPluginStatus("sessionforge")).toEqual({ id: "sessionforge", path: "/y", enabled: true, status: "running" }); + }); + + it("returns null when the plugin isn't installed", async () => { + execFileHandler = (_cmd, _args, callback) => callback(null, jsonResult([])); + expect(await getPluginStatus("sessionforge")).toBeNull(); + }); + }); + + describe("pluginInstallDir", () => { + it("is a stable path under the user's home directory", () => { + expect(pluginInstallDir()).toContain(".sessionforge"); + expect(pluginInstallDir()).toContain("paseo-plugin"); + }); + }); +}); diff --git a/packages/cli/src/core/paseo-wire.server.ts b/packages/cli/src/core/paseo-wire.server.ts new file mode 100644 index 0000000..d0a317c --- /dev/null +++ b/packages/cli/src/core/paseo-wire.server.ts @@ -0,0 +1,96 @@ +import { execFile } from "node:child_process"; +import { createWriteStream, existsSync } from "node:fs"; +import { mkdir, readFile, 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"; + +const execFileAsync = promisify(execFile); + +export const DEFAULT_PLUGIN_ID = "sessionforge"; +export const PLUGIN_ARCHIVE_NAME = "sessionforge-paseo-plugin.tar.gz"; + +/** Where a downloaded plugin gets extracted to before `paseo plugin install` points at it — a stable, + * reusable path so re-running wire-paseo cleanly replaces a previous install with a newer one. */ +export function pluginInstallDir(): string { + return join(homedir(), ".sessionforge", "paseo-plugin"); +} + +/** Confirms the `paseo` CLI is actually on PATH before attempting anything that shells out to it. */ +export async function isPaseoCliAvailable(): Promise { + try { + await execFileAsync("paseo", ["--version"]); + return true; + } catch { + return false; + } +} + +interface DaemonStatus { + home: string; +} + +/** The daemon's own state directory (holding config.json) — via `paseo daemon status --json`, not + * guessed, since it's configurable and this must work for both default and custom PASEO_HOME setups. */ +async function getDaemonHome(): Promise { + const { stdout } = await execFileAsync("paseo", ["daemon", "status", "--json"]); + const status = JSON.parse(stdout) as DaemonStatus; + if (!status.home) throw new Error("`paseo daemon status --json` did not report a home directory."); + return status.home; +} + +/** + * Plugins are trusted, unsandboxed code — this only ever reads the daemon's `pluginsEnabled` setting, it + * never writes it. A missing field is treated as disabled, matching Paseo's own documented default. + */ +export async function arePluginsEnabled(): Promise { + const home = await getDaemonHome(); + const configPath = join(home, "config.json"); + const raw = await readFile(configPath, "utf8"); + const config = JSON.parse(raw) as { pluginsEnabled?: boolean }; + return config.pluginsEnabled === true; +} + +/** Downloads a version-matched release asset — not `releases/latest`, so an older sessionforge binary + * 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 { + 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)); +} + +/** 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. */ +export async function extractPluginArchive(archivePath: string, destDir: string): Promise { + await rm(destDir, { recursive: true, force: true }); + await mkdir(destDir, { recursive: true }); + await execFileAsync("tar", ["-xzf", archivePath, "-C", destDir]); +} + +interface PluginInstallResult { + id: string; + path: string; + enabled: boolean; + status: string; + error?: string; +} + +export async function installPluginDirectory(pluginDir: string, id: string = DEFAULT_PLUGIN_ID): Promise { + const { stdout } = await execFileAsync("paseo", ["plugin", "install", pluginDir, "--id", id, "--json"]); + return JSON.parse(stdout) as PluginInstallResult; +} + +export async function getPluginStatus(id: string = DEFAULT_PLUGIN_ID): Promise { + const { stdout } = await execFileAsync("paseo", ["plugin", "ls", "--json"]); + const plugins = JSON.parse(stdout) as PluginInstallResult[]; + return plugins.find((plugin) => plugin.id === id) ?? null; +} + +export function pluginArchiveExists(path: string): boolean { + return existsSync(path); +} diff --git a/scripts/package-plugin.mjs b/scripts/package-plugin.mjs new file mode 100644 index 0000000..ea08208 --- /dev/null +++ b/scripts/package-plugin.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node +// Packages this repo's Paseo plugin (index.ts, main.client.tsx, src/server/*) into a self-contained +// directory, then tars it — the artifact `sessionforge wire-paseo` downloads and points a local +// `paseo plugin install` at, so a user doesn't need to clone this whole monorepo just to get the plugin. +// +// This can't just be "git clone the repo and let Paseo build it" (verified empirically): Paseo's +// git-source plugin install does a fresh checkout and bundles the plugin directly, with no npm-workspace +// dependency resolution step, so it fails to resolve the plugin's one real runtime dependency, +// `@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 { execFileSync } from "node:child_process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); +const buildDir = join(repoRoot, "build"); +const stageDir = join(buildDir, "paseo-plugin"); +const archivePath = join(buildDir, "sessionforge-paseo-plugin.tar.gz"); + +// Everything the plugin's own source actually imports (`index.ts`, `main.client.tsx`, `src/server/*`) — +// verified by reading the actual import statements, not guessed. `tsconfig.json` isn't required at +// runtime (Paseo transpiles the plugin's TS/TSX on the fly) but costs nothing to include. +const PLUGIN_FILES = ["paseo-plugin.json", "paseo-plugin.d.ts", "index.ts", "main.client.tsx", "package.json", "tsconfig.json", "src/server"]; + +function main() { + const cliDist = join(repoRoot, "packages", "cli", "dist"); + if (!existsSync(cliDist)) { + throw new Error(`${cliDist} is missing — run "npm run build" (or "npm install", which does it via postinstall) first.`); + } + + rmSync(stageDir, { recursive: true, force: true }); + mkdirSync(stageDir, { recursive: true }); + + for (const rel of PLUGIN_FILES) { + cpSync(join(repoRoot, rel), join(stageDir, rel), { recursive: true }); + } + + // A real copy, not the workspace symlink at the source repo's own node_modules/@aadaa88/sessionforge — + // symlinks don't survive being tarred and extracted on someone else's machine. + const cliPackageDest = join(stageDir, "node_modules", "@aadaa88", "sessionforge"); + mkdirSync(cliPackageDest, { recursive: true }); + cpSync(cliDist, join(cliPackageDest, "dist"), { recursive: true }); + cpSync(join(repoRoot, "packages", "cli", "package.json"), join(cliPackageDest, "package.json")); + cpSync(join(repoRoot, "packages", "cli", "LICENSE"), join(cliPackageDest, "LICENSE")); + + 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. + execFileSync("tar", ["-czf", archivePath, "-C", stageDir, "."], { stdio: "inherit" }); + + console.log(`Packaged Paseo plugin: ${archivePath}`); +} + +main();