Add native installers and release bundles

This commit is contained in:
Advait Paliwal
2026-03-24 10:38:59 -07:00
parent 0f62901ab0
commit dd9f5cfb90
6 changed files with 665 additions and 13 deletions

View File

@@ -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();

View File

@@ -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
}
}

212
scripts/install/install.sh Normal file
View File

@@ -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" <<EOF
#!/bin/sh
set -eu
exec "$INSTALL_APP_DIR/$bundle_name/feynman" "\$@"
EOF
chmod 0755 "$INSTALL_BIN_DIR/feynman"
add_to_path
case "$path_action" in
added)
step "PATH updated for future shells in $path_profile"
step "Run now: export PATH=\"$INSTALL_BIN_DIR:\$PATH\" && feynman"
;;
configured)
step "PATH is already configured for future shells in $path_profile"
step "Run now: export PATH=\"$INSTALL_BIN_DIR:\$PATH\" && feynman"
;;
skipped)
step "PATH update skipped"
step "Run now: export PATH=\"$INSTALL_BIN_DIR:\$PATH\" && feynman"
;;
*)
step "$INSTALL_BIN_DIR is already on PATH"
step "Run: feynman"
;;
esac
printf 'Feynman %s installed successfully.\n' "$resolved_version"