diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 404f2e7..536b33a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,11 +1,42 @@ -name: Publish to npm +name: Publish and Release on: push: branches: [main] + workflow_dispatch: jobs: - publish: + version-check: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + should_publish: ${{ steps.version.outputs.should_publish }} + should_build_release: ${{ steps.version.outputs.should_build_release }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24.14.0 + - id: version + shell: bash + run: | + CURRENT=$(npm view @companion-ai/feynman version 2>/dev/null || echo "0.0.0") + LOCAL=$(node -p "require('./package.json').version") + echo "version=$LOCAL" >> "$GITHUB_OUTPUT" + if [ "$CURRENT" != "$LOCAL" ]; then + echo "should_publish=true" >> "$GITHUB_OUTPUT" + echo "should_build_release=true" >> "$GITHUB_OUTPUT" + elif [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then + echo "should_publish=false" >> "$GITHUB_OUTPUT" + echo "should_build_release=true" >> "$GITHUB_OUTPUT" + else + echo "should_publish=false" >> "$GITHUB_OUTPUT" + echo "should_build_release=false" >> "$GITHUB_OUTPUT" + fi + + publish-npm: + needs: version-check + if: needs.version-check.outputs.should_publish == 'true' runs-on: ubuntu-latest permissions: contents: read @@ -13,19 +44,64 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24.14.0 registry-url: https://registry.npmjs.org - - run: npm ci + - run: npm ci --ignore-scripts - run: npm run build - run: npm test - - name: Publish if version changed - run: | - CURRENT=$(npm view @companion-ai/feynman version 2>/dev/null || echo "0.0.0") - LOCAL=$(node -p "require('./package.json').version") - if [ "$CURRENT" != "$LOCAL" ]; then - npm publish --access public - else - echo "Version $LOCAL already published, skipping" - fi + - run: npm publish --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + build-native-bundles: + needs: version-check + if: needs.version-check.outputs.should_build_release == 'true' + strategy: + fail-fast: false + matrix: + include: + - id: linux-x64 + os: ubuntu-latest + - id: darwin-x64 + os: macos-13 + - id: darwin-arm64 + os: macos-14 + - id: win32-x64 + os: windows-latest + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24.14.0 + - run: npm ci --ignore-scripts + - run: npm run build + - run: npm run build:native-bundle + - uses: actions/upload-artifact@v4 + with: + name: native-${{ matrix.id }} + path: dist/release/* + + release-github: + needs: + - version-check + - publish-npm + - build-native-bundles + if: needs.version-check.outputs.should_publish == 'true' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + path: release-assets + merge-multiple: true + - shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ needs.version-check.outputs.version }} + run: | + gh release create "v$VERSION" release-assets/* \ + --title "v$VERSION" \ + --notes "Standalone Feynman bundles for native installation." \ + --target "$GITHUB_SHA" diff --git a/README.md b/README.md index 3a09f2f..2e84b61 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,16 @@ The open source AI research agent +```bash +curl -fsSL https://raw.githubusercontent.com/getcompanion-ai/feynman/main/scripts/install/install.sh | bash +``` + +```powershell +irm https://raw.githubusercontent.com/getcompanion-ai/feynman/main/scripts/install/install.ps1 | iex +``` + +Or install the npm fallback: + ```bash npm install -g @companion-ai/feynman ``` diff --git a/package.json b/package.json index 6f95847..420323f 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ ], "scripts": { "build": "tsc -p tsconfig.build.json", + "build:native-bundle": "node ./scripts/build-native-bundle.mjs", "dev": "tsx src/index.ts", "prepack": "node ./scripts/prepare-runtime-workspace.mjs", "postinstall": "node ./scripts/patch-embedded-pi.mjs", diff --git a/scripts/build-native-bundle.mjs b/scripts/build-native-bundle.mjs new file mode 100644 index 0000000..2db5f76 --- /dev/null +++ b/scripts/build-native-bundle.mjs @@ -0,0 +1,271 @@ +import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; + +const appRoot = resolve(import.meta.dirname, ".."); +const packageJson = JSON.parse(readFileSync(resolve(appRoot, "package.json"), "utf8")); +const packageLockPath = resolve(appRoot, "package-lock.json"); +const bundledNodeVersion = process.env.FEYNMAN_BUNDLED_NODE_VERSION ?? process.version.slice(1); + +function fail(message) { + console.error(`[feynman] ${message}`); + process.exit(1); +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + stdio: "inherit", + ...options, + }); + if (result.status !== 0) { + fail(`${command} ${args.join(" ")} failed with code ${result.status ?? 1}`); + } +} + +function runCapture(command, args, options = {}) { + const result = spawnSync(command, args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + ...options, + }); + if (result.status !== 0) { + const errorOutput = result.stderr?.trim() || result.stdout?.trim() || "unknown error"; + fail(`${command} ${args.join(" ")} failed: ${errorOutput}`); + } + return result.stdout.trim(); +} + +function detectTarget() { + if (process.platform === "darwin" && process.arch === "arm64") { + return { + id: "darwin-arm64", + nodePlatform: "darwin", + nodeArch: "arm64", + bundleExtension: "tar.gz", + launcher: "unix", + }; + } + if (process.platform === "darwin" && process.arch === "x64") { + return { + id: "darwin-x64", + nodePlatform: "darwin", + nodeArch: "x64", + bundleExtension: "tar.gz", + launcher: "unix", + }; + } + if (process.platform === "linux" && process.arch === "arm64") { + return { + id: "linux-arm64", + nodePlatform: "linux", + nodeArch: "arm64", + bundleExtension: "tar.gz", + launcher: "unix", + }; + } + if (process.platform === "linux" && process.arch === "x64") { + return { + id: "linux-x64", + nodePlatform: "linux", + nodeArch: "x64", + bundleExtension: "tar.gz", + launcher: "unix", + }; + } + if (process.platform === "win32" && process.arch === "arm64") { + return { + id: "win32-arm64", + nodePlatform: "win", + nodeArch: "arm64", + bundleExtension: "zip", + launcher: "windows", + }; + } + if (process.platform === "win32" && process.arch === "x64") { + return { + id: "win32-x64", + nodePlatform: "win", + nodeArch: "x64", + bundleExtension: "zip", + launcher: "windows", + }; + } + + fail(`unsupported platform ${process.platform}/${process.arch}`); +} + +function nodeArchiveName(target) { + if (target.nodePlatform === "win") { + return `node-v${bundledNodeVersion}-${target.nodePlatform}-${target.nodeArch}.zip`; + } + return `node-v${bundledNodeVersion}-${target.nodePlatform}-${target.nodeArch}.tar.xz`; +} + +function ensureBundledWorkspace() { + run(process.execPath, [resolve(appRoot, "scripts", "prepare-runtime-workspace.mjs")], { cwd: appRoot }); +} + +function copyPackageFiles(appDir) { + cpSync(resolve(appRoot, "package.json"), resolve(appDir, "package.json")); + for (const entry of packageJson.files) { + const normalized = entry.endsWith("/") ? entry.slice(0, -1) : entry; + const source = resolve(appRoot, normalized); + if (!existsSync(source)) continue; + const destination = resolve(appDir, normalized); + mkdirSync(dirname(destination), { recursive: true }); + cpSync(source, destination, { recursive: true }); + } + + cpSync(packageLockPath, resolve(appDir, "package-lock.json")); +} + +function extractTarball(archivePath, destination, compressionFlag) { + run("tar", [compressionFlag, archivePath, "-C", destination]); +} + +function extractZip(archivePath, destination) { + if (process.platform === "win32") { + run("powershell", [ + "-NoProfile", + "-Command", + `Expand-Archive -LiteralPath '${archivePath.replace(/'/g, "''")}' -DestinationPath '${destination.replace(/'/g, "''")}' -Force`, + ]); + return; + } + + run("unzip", ["-q", archivePath, "-d", destination]); +} + +function findSingleDirectory(path) { + const entries = readdirSync(path).filter((entry) => !entry.startsWith(".")); + if (entries.length !== 1) { + fail(`expected exactly one directory in ${path}, found: ${entries.join(", ")}`); + } + const child = resolve(path, entries[0]); + if (!statSync(child).isDirectory()) { + fail(`expected ${child} to be a directory`); + } + return child; +} + +function installBundledNode(bundleRoot, target, stagingRoot) { + const archiveName = nodeArchiveName(target); + const archivePath = resolve(stagingRoot, archiveName); + const url = `https://nodejs.org/dist/v${bundledNodeVersion}/${archiveName}`; + + run("curl", ["-fsSL", url, "-o", archivePath]); + + const extractRoot = resolve(stagingRoot, "node-dist"); + mkdirSync(extractRoot, { recursive: true }); + if (archiveName.endsWith(".zip")) { + extractZip(archivePath, extractRoot); + } else { + extractTarball(archivePath, extractRoot, "-xJf"); + } + + const extractedDir = findSingleDirectory(extractRoot); + renameSync(extractedDir, resolve(bundleRoot, "node")); +} + +function writeLauncher(bundleRoot, target) { + if (target.launcher === "unix") { + const launcherPath = resolve(bundleRoot, "feynman"); + writeFileSync( + launcherPath, + [ + "#!/bin/sh", + "set -eu", + 'ROOT="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"', + 'exec "$ROOT/node/bin/node" "$ROOT/app/bin/feynman.js" "$@"', + "", + ].join("\n"), + "utf8", + ); + chmodSync(launcherPath, 0o755); + return; + } + + writeFileSync( + resolve(bundleRoot, "feynman.cmd"), + [ + "@echo off", + "setlocal", + 'set "ROOT=%~dp0"', + '"%ROOT%node\\node.exe" "%ROOT%app\\bin\\feynman.js" %*', + "", + ].join("\r\n"), + "utf8", + ); + writeFileSync( + resolve(bundleRoot, "feynman.ps1"), + [ + '$Root = Split-Path -Parent $MyInvocation.MyCommand.Path', + '& "$Root\\node\\node.exe" "$Root\\app\\bin\\feynman.js" @args', + "", + ].join("\r\n"), + "utf8", + ); +} + +function validateBundle(bundleRoot, target) { + const nodeExecutable = + target.launcher === "windows" + ? resolve(bundleRoot, "node", "node.exe") + : resolve(bundleRoot, "node", "bin", "node"); + + run(nodeExecutable, ["-e", "require('./app/.feynman/npm/node_modules/better-sqlite3'); console.log('better-sqlite3 ok')"], { + cwd: bundleRoot, + }); +} + +function packBundle(bundleRoot, target, outDir) { + const archiveName = `${basename(bundleRoot)}.${target.bundleExtension}`; + const archivePath = resolve(outDir, archiveName); + rmSync(archivePath, { force: true }); + + if (target.bundleExtension === "zip") { + if (process.platform === "win32") { + run("powershell", [ + "-NoProfile", + "-Command", + `Compress-Archive -Path '${bundleRoot.replace(/'/g, "''")}\\*' -DestinationPath '${archivePath.replace(/'/g, "''")}' -Force`, + ]); + } else { + run("zip", ["-qr", archivePath, basename(bundleRoot)], { cwd: resolve(bundleRoot, "..") }); + } + return archivePath; + } + + run("tar", ["-czf", archivePath, basename(bundleRoot)], { cwd: resolve(bundleRoot, "..") }); + return archivePath; +} + +function main() { + const target = detectTarget(); + const stagingRoot = mkdtempSync(join(tmpdir(), "feynman-native-")); + const outDir = resolve(appRoot, "dist", "release"); + const bundleRoot = resolve(stagingRoot, `feynman-${packageJson.version}-${target.id}`); + const appDir = resolve(bundleRoot, "app"); + + mkdirSync(outDir, { recursive: true }); + mkdirSync(appDir, { recursive: true }); + + ensureBundledWorkspace(); + copyPackageFiles(appDir); + run("npm", ["ci", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund", "--loglevel", "error"], { cwd: appDir }); + + const appFeynmanDir = resolve(appDir, ".feynman"); + extractTarball(resolve(appFeynmanDir, "runtime-workspace.tgz"), appFeynmanDir, "-xzf"); + rmSync(resolve(appFeynmanDir, "runtime-workspace.tgz"), { force: true }); + run(process.execPath, [resolve(appDir, "scripts", "patch-embedded-pi.mjs")], { cwd: appDir }); + + installBundledNode(bundleRoot, target, stagingRoot); + writeLauncher(bundleRoot, target); + validateBundle(bundleRoot, target); + + const archivePath = packBundle(bundleRoot, target, outDir); + console.log(`[feynman] native bundle ready: ${archivePath}`); +} + +main(); diff --git a/scripts/install/install.ps1 b/scripts/install/install.ps1 new file mode 100644 index 0000000..8a04849 --- /dev/null +++ b/scripts/install/install.ps1 @@ -0,0 +1,82 @@ +param( + [string]$Version = "latest" +) + +$ErrorActionPreference = "Stop" + +function Resolve-Version { + param([string]$RequestedVersion) + + if ($RequestedVersion -and $RequestedVersion -ne "latest") { + return $RequestedVersion.TrimStart("v") + } + + $release = Invoke-RestMethod -Uri "https://api.github.com/repos/getcompanion-ai/feynman/releases/latest" + if (-not $release.tag_name) { + throw "Failed to resolve the latest Feynman release version." + } + + return $release.tag_name.TrimStart("v") +} + +function Get-ArchSuffix { + $arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture + switch ($arch.ToString()) { + "X64" { return "x64" } + "Arm64" { return "arm64" } + default { throw "Unsupported architecture: $arch" } + } +} + +$resolvedVersion = Resolve-Version -RequestedVersion $Version +$archSuffix = Get-ArchSuffix +$bundleName = "feynman-$resolvedVersion-win32-$archSuffix" +$archiveName = "$bundleName.zip" +$baseUrl = if ($env:FEYNMAN_INSTALL_BASE_URL) { $env:FEYNMAN_INSTALL_BASE_URL } else { "https://github.com/getcompanion-ai/feynman/releases/download/v$resolvedVersion" } +$downloadUrl = "$baseUrl/$archiveName" + +$installRoot = Join-Path $env:LOCALAPPDATA "Programs\feynman" +$installBinDir = Join-Path $installRoot "bin" +$bundleDir = Join-Path $installRoot $bundleName + +$tmpDir = Join-Path ([System.IO.Path]::GetTempPath()) ("feynman-install-" + [System.Guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Path $tmpDir | Out-Null + +try { + $archivePath = Join-Path $tmpDir $archiveName + Invoke-WebRequest -Uri $downloadUrl -OutFile $archivePath + + New-Item -ItemType Directory -Path $installRoot -Force | Out-Null + if (Test-Path $bundleDir) { + Remove-Item -Recurse -Force $bundleDir + } + + Expand-Archive -LiteralPath $archivePath -DestinationPath $installRoot -Force + + New-Item -ItemType Directory -Path $installBinDir -Force | Out-Null + + $shimPath = Join-Path $installBinDir "feynman.cmd" + @" +@echo off +"$bundleDir\feynman.cmd" %* +"@ | Set-Content -Path $shimPath -Encoding ASCII + + $currentUserPath = [Environment]::GetEnvironmentVariable("Path", "User") + if (-not $currentUserPath.Split(';').Contains($installBinDir)) { + $updatedPath = if ([string]::IsNullOrWhiteSpace($currentUserPath)) { + $installBinDir + } else { + "$currentUserPath;$installBinDir" + } + [Environment]::SetEnvironmentVariable("Path", $updatedPath, "User") + Write-Host "Updated user PATH. Open a new shell to run feynman." + } else { + Write-Host "$installBinDir is already on PATH." + } + + Write-Host "Feynman $resolvedVersion installed successfully." +} finally { + if (Test-Path $tmpDir) { + Remove-Item -Recurse -Force $tmpDir + } +} diff --git a/scripts/install/install.sh b/scripts/install/install.sh new file mode 100644 index 0000000..e172834 --- /dev/null +++ b/scripts/install/install.sh @@ -0,0 +1,212 @@ +#!/bin/sh + +set -eu + +VERSION="${1:-latest}" +INSTALL_BIN_DIR="${FEYNMAN_INSTALL_BIN_DIR:-$HOME/.local/bin}" +INSTALL_APP_DIR="${FEYNMAN_INSTALL_APP_DIR:-$HOME/.local/share/feynman}" +SKIP_PATH_UPDATE="${FEYNMAN_INSTALL_SKIP_PATH_UPDATE:-0}" +path_action="already" +path_profile="" + +step() { + printf '==> %s\n' "$1" +} + +normalize_version() { + case "$1" in + "" | latest) + printf 'latest\n' + ;; + v*) + printf '%s\n' "${1#v}" + ;; + *) + printf '%s\n' "$1" + ;; + esac +} + +download_file() { + url="$1" + output="$2" + + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$url" -o "$output" + return + fi + + if command -v wget >/dev/null 2>&1; then + wget -q -O "$output" "$url" + return + fi + + echo "curl or wget is required to install Feynman." >&2 + exit 1 +} + +download_text() { + url="$1" + + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$url" + return + fi + + if command -v wget >/dev/null 2>&1; then + wget -q -O - "$url" + return + fi + + echo "curl or wget is required to install Feynman." >&2 + exit 1 +} + +add_to_path() { + path_action="already" + path_profile="" + + case ":$PATH:" in + *":$INSTALL_BIN_DIR:"*) + return + ;; + esac + + if [ "$SKIP_PATH_UPDATE" = "1" ]; then + path_action="skipped" + return + fi + + profile="${FEYNMAN_INSTALL_SHELL_PROFILE:-$HOME/.profile}" + if [ -z "${FEYNMAN_INSTALL_SHELL_PROFILE:-}" ]; then + case "${SHELL:-}" in + */zsh) + profile="$HOME/.zshrc" + ;; + */bash) + profile="$HOME/.bashrc" + ;; + esac + fi + + path_profile="$profile" + path_line="export PATH=\"$INSTALL_BIN_DIR:\$PATH\"" + if [ -f "$profile" ] && grep -F "$path_line" "$profile" >/dev/null 2>&1; then + path_action="configured" + return + fi + + { + printf '\n# Added by Feynman installer\n' + printf '%s\n' "$path_line" + } >>"$profile" + path_action="added" +} + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "$1 is required to install Feynman." >&2 + exit 1 + fi +} + +resolve_version() { + normalized_version="$(normalize_version "$VERSION")" + + if [ "$normalized_version" != "latest" ]; then + printf '%s\n' "$normalized_version" + return + fi + + release_json="$(download_text "https://api.github.com/repos/getcompanion-ai/feynman/releases/latest")" + resolved="$(printf '%s\n' "$release_json" | sed -n 's/.*"tag_name":[[:space:]]*"v\([^"]*\)".*/\1/p' | head -n 1)" + + if [ -z "$resolved" ]; then + echo "Failed to resolve the latest Feynman release version." >&2 + exit 1 + fi + + printf '%s\n' "$resolved" +} + +case "$(uname -s)" in + Darwin) + os="darwin" + ;; + Linux) + os="linux" + ;; + *) + echo "install.sh supports macOS and Linux. Use install.ps1 on Windows." >&2 + exit 1 + ;; +esac + +case "$(uname -m)" in + x86_64 | amd64) + arch="x64" + ;; + arm64 | aarch64) + arch="arm64" + ;; + *) + echo "Unsupported architecture: $(uname -m)" >&2 + exit 1 + ;; +esac + +require_command mktemp +require_command tar + +resolved_version="$(resolve_version)" +asset_target="$os-$arch" +bundle_name="feynman-${resolved_version}-${asset_target}" +archive_name="${bundle_name}.tar.gz" +base_url="${FEYNMAN_INSTALL_BASE_URL:-https://github.com/getcompanion-ai/feynman/releases/download/v${resolved_version}}" +download_url="${base_url}/${archive_name}" + +step "Installing Feynman ${resolved_version} for ${asset_target}" + +tmp_dir="$(mktemp -d)" +cleanup() { + rm -rf "$tmp_dir" +} +trap cleanup EXIT INT TERM + +archive_path="$tmp_dir/$archive_name" +download_file "$download_url" "$archive_path" + +mkdir -p "$INSTALL_APP_DIR" +rm -rf "$INSTALL_APP_DIR/$bundle_name" +tar -xzf "$archive_path" -C "$INSTALL_APP_DIR" + +mkdir -p "$INSTALL_BIN_DIR" +cat >"$INSTALL_BIN_DIR/feynman" <