diff --git a/installer/README.md b/installer/README.md
new file mode 100644
index 0000000..b293c2b
--- /dev/null
+++ b/installer/README.md
@@ -0,0 +1,11 @@
+# Safe Chain Installer - WIP
+
+This directory contains the build scripts and resources for creating standalone Safe Chain installers for different platforms.
+
+## Overview
+
+The installer bundles the Safe Chain Node.js application into a standalone binary using [@yao-pkg/pkg](https://github.com/yao-pkg/pkg) and creates platform-specific installers that:
+
+1. Install the `safe-chain` binary to the system PATH
+2. Generate and install the CA certificate in the OS trust store
+3. Configure the system for automatic MITM proxy interception
diff --git a/installer/build.js b/installer/build.js
index 86b02ea..e139534 100644
--- a/installer/build.js
+++ b/installer/build.js
@@ -65,6 +65,11 @@ async function bundleWithEsbuild() {
loader: {
'.json': 'json', // Inline JSON files
},
+ // We need to inject a polyfill because:
+ // source code is ESM (uses import.meta.url)
+ // target is CommonJS (required by pkg)
+ // CommonJS doesn't have import.meta.url, and ESM doesn't have __filename/__dirname
+ // create a fake import.meta.url from __filename
banner: {
js: `// Polyfill for import.meta.url in CommonJS
var __filename = __filename || (() => {
@@ -85,13 +90,10 @@ var import_meta_url = typeof __filename !== 'undefined' ? require('url').pathToF
sourcemap: false,
});
- console.log('✓ Bundle created at:', outputFile);
+ console.log('Bundle created at:', outputFile);
return outputFile;
}
-/**
- * Build macOS binary and installer
- */
async function buildMacOS() {
console.log('Building macOS binary...');
@@ -195,12 +197,27 @@ async function createMacOSInstaller(binaryPath) {
writeFileSync(join(scriptsDir, 'preinstall'), preinstallScript, { mode: 0o755 });
writeFileSync(join(scriptsDir, 'postinstall'), postinstallScript, { mode: 0o755 });
writeFileSync(join(installerDir, 'uninstall.sh'), uninstallScript, { mode: 0o755 });
+
+ // Run pkgbuild to create the .pkg file
+ console.log('Running pkgbuild...');
+ const pkgName = 'SafeChain.pkg';
+ const pkgPath = join(installerDir, pkgName);
- console.log('✓ macOS installer package created at:', installerDir);
- console.log('');
- console.log('To create a .pkg installer, run:');
- console.log(` cd ${installerDir}`);
- console.log(' pkgbuild --root resources --scripts scripts --identifier com.aikido.safe-chain --version 1.0.0 --install-location /tmp/safe-chain-install SafeChain.pkg');
+ // Get version from package.json
+ const packageJson = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf8'));
+ const version = packageJson.version;
+
+ const pkgbuildArgs = [
+ '--root', resourcesDir,
+ '--scripts', scriptsDir,
+ '--identifier', 'com.aikido.safe-chain',
+ '--version', version,
+ '--install-location', '/tmp/safe-chain-install',
+ pkgPath
+ ];
+
+ await execAsync(`pkgbuild ${pkgbuildArgs.join(' ')}`);
+ console.log(`Mac OS Installer created at: ${pkgPath}`);
}
/**
diff --git a/installer/generate-certs.js b/installer/generate-certs.js
index 440a542..891ae96 100644
--- a/installer/generate-certs.js
+++ b/installer/generate-certs.js
@@ -11,7 +11,7 @@ import { join } from 'node:path';
/**
* Generate certificate files (simple version for installer build)
- * For the full CLI version with nice output, use: safe-chain generate-cert
+ * For the full CLI version with nice output, use: safe-chain _generate-cert
*
* @param {string} outputDir - Directory to save certificate files
* @returns {Promise<{certPath: string, keyPath: string}>}
diff --git a/installer/package.json b/installer/package.json
index bcc6cfb..2499edd 100644
--- a/installer/package.json
+++ b/installer/package.json
@@ -1,7 +1,7 @@
{
"name": "@aikidosec/safe-chain-installer",
"version": "1.0.0",
- "description": "Installer for Aikido Safe Chain - creates standalone binaries and installs system certificates",
+ "description": "Installer for Aikido Safe Chain - creates standalone binaries and contains pre- and post install functionality",
"private": true,
"type": "module",
"scripts": {
diff --git a/installer/scripts/darwin_build_installer.sh b/installer/scripts/darwin_build_installer.sh
new file mode 100755
index 0000000..91ade1c
--- /dev/null
+++ b/installer/scripts/darwin_build_installer.sh
@@ -0,0 +1,21 @@
+#!/bin/bash
+set -e
+
+# Get the directory where this script is located
+SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+INSTALLER_ROOT="${SCRIPT_DIR}/.."
+
+echo "=== Building Safe Chain Installer for macOS ==="
+
+# Ensure we are in the installer directory
+cd "${INSTALLER_ROOT}"
+
+# Install dependencies
+echo "Installing build dependencies..."
+npm install
+
+# Build the binary and installer using the Node.js build script
+echo "Building binary and installer..."
+node build.js --platform=macos
+
+echo "Done."
diff --git a/installer/scripts/darwin_postinstall.sh b/installer/scripts/darwin_postinstall.sh
index 54ae030..3db74bf 100644
--- a/installer/scripts/darwin_postinstall.sh
+++ b/installer/scripts/darwin_postinstall.sh
@@ -12,32 +12,32 @@ mkdir -p /usr/local/bin
cp "${INSTALL_LOCATION}/safe-chain" /usr/local/bin/safe-chain
chmod +x /usr/local/bin/safe-chain
-# Setup certificate directory in user's home
-# The proxy will use ~/.safe-chain/certs/ so we need to ensure it exists
-# and install the certificate from there
-# Get the actual user (not root) who invoked the installer
-# When using 'installer' command, SUDO_USER is not set, so we use the console user
-ACTUAL_USER=$(stat -f '%Su' /dev/console)
-
-# Get the home directory of the actual user
-USER_HOME=$(eval echo "~${ACTUAL_USER}")
-
-CERT_DIR="${USER_HOME}/.safe-chain/certs"
+# Setup system-wide certificate directory
+# We use a shared location so we don't need to worry about which user is running the agent
+CERT_DIR="/usr/local/share/safe-chain/certs"
mkdir -p "${CERT_DIR}"
-# Set ownership immediately after creating directory
-chown -R "${ACTUAL_USER}:staff" "${USER_HOME}/.safe-chain"
# Generate certificate if it doesn't exist
-# This ensures the same cert is used by both the proxy and system trust store
if [ ! -f "${CERT_DIR}/ca-cert.pem" ]; then
echo "Generating Safe Chain CA certificate..."
- # Run as the actual user with their HOME set, not root
- sudo -u "${ACTUAL_USER}" HOME="${USER_HOME}" /usr/local/bin/safe-chain generate-cert --output "${CERT_DIR}"
+ # Run as root (installer context) - no need to switch users
+ /usr/local/bin/safe-chain _generate-cert --output "${CERT_DIR}"
fi
-# Set correct ownership (important since installer runs as root)
-# Do this AFTER generating certificates so they get the right ownership too
-chown -R "${ACTUAL_USER}:staff" "${USER_HOME}/.safe-chain"
+# Set permissions so any user can read the certs (required for the agent to load them)
+# Directory is executable/readable by all
+chmod 755 "/usr/local/share/safe-chain"
+chmod 755 "${CERT_DIR}"
+
+# PUBLIC Certificate: Readable by everyone (644)
+chmod 644 "${CERT_DIR}/ca-cert.pem"
+
+# PRIVATE Key: Readable ONLY by the owner (600)
+# This is critical for security.
+chmod 600 "${CERT_DIR}/ca-key.pem"
+
+# Ensure the actual user owns the files so the agent (running as user) can read them
+chown -R "${ACTUAL_USER}:staff" "/usr/local/share/safe-chain"
# Install certificate in system trust store
echo "Installing Safe Chain CA certificate in system trust store..."
@@ -52,9 +52,15 @@ fi
# Start safe-chain as a background service
echo "Starting Safe Chain proxy service..."
+# Get the actual user to install the LaunchAgent in their home
+# (We still need this for the LaunchAgent, but not for file permissions)
+ACTUAL_USER=$(stat -f '%Su' /dev/console)
+USER_HOME=$(eval echo "~${ACTUAL_USER}")
+
# Create LaunchAgent for auto-start on login
LAUNCH_AGENT_DIR="${USER_HOME}/Library/LaunchAgents"
mkdir -p "${LAUNCH_AGENT_DIR}"
+chown "${ACTUAL_USER}:staff" "${LAUNCH_AGENT_DIR}"
PLIST_PATH="${LAUNCH_AGENT_DIR}/com.aikido.safe-chain.plist"
cat > "${PLIST_PATH}" << EOF
@@ -77,6 +83,8 @@ cat > "${PLIST_PATH}" << EOF
http://localhost:8080
NODE_EXTRA_CA_CERTS
${CERT_DIR}/ca-cert.pem
+ SAFE_CHAIN_CERT_DIR
+ ${CERT_DIR}
RunAtLoad
@@ -93,9 +101,6 @@ EOF
# Set correct ownership for plist
chown "${ACTUAL_USER}:staff" "${PLIST_PATH}"
-# Set correct ownership for plist
-chown "${ACTUAL_USER}:staff" "${PLIST_PATH}"
-
# Load the LaunchAgent to start the service now
# Need to run as the actual user, not root
sudo -u "${ACTUAL_USER}" launchctl load "${PLIST_PATH}" 2>/dev/null || true
diff --git a/installer/scripts/darwin_uninstall.sh b/installer/scripts/darwin_uninstall.sh
index 79ab745..484d449 100644
--- a/installer/scripts/darwin_uninstall.sh
+++ b/installer/scripts/darwin_uninstall.sh
@@ -3,36 +3,54 @@ set -e
echo "Uninstalling Safe Chain..."
-USER_HOME="${HOME}"
-if [ -z "${USER_HOME}" ]; then
- USER_HOME=~
+# Get the actual user
+if [ -n "${SUDO_USER}" ]; then
+ ACTUAL_USER="${SUDO_USER}"
+else
+ ACTUAL_USER=$(stat -f '%Su' /dev/console)
fi
+# Get the home directory of the actual user
+USER_HOME=$(eval echo "~${ACTUAL_USER}")
+
+echo "Detected user: ${ACTUAL_USER}"
+echo "User home: ${USER_HOME}"
+
# Stop and unload the LaunchAgent
PLIST_PATH="${USER_HOME}/Library/LaunchAgents/com.aikido.safe-chain.plist"
+SERVICE_LABEL="com.aikido.safe-chain"
+
+echo "Stopping Safe Chain service..."
if [ -f "${PLIST_PATH}" ]; then
- echo "Stopping Safe Chain service..."
- launchctl unload "${PLIST_PATH}" 2>/dev/null || true
+ # Run launchctl as the user
+ sudo -u "${ACTUAL_USER}" launchctl unload "${PLIST_PATH}" 2>/dev/null || true
rm -f "${PLIST_PATH}"
fi
+# Ensure service is removed even if plist is gone
+sudo -u "${ACTUAL_USER}" launchctl remove "${SERVICE_LABEL}" 2>/dev/null || true
+
# Remove system-wide environment variables
echo "Removing proxy environment variables..."
-launchctl unsetenv HTTPS_PROXY 2>/dev/null || true
-launchctl unsetenv GLOBAL_AGENT_HTTP_PROXY 2>/dev/null || true
-launchctl unsetenv NODE_EXTRA_CA_CERTS 2>/dev/null || true
+# Run launchctl as the user
+sudo -u "${ACTUAL_USER}" launchctl unsetenv HTTPS_PROXY 2>/dev/null || true
+sudo -u "${ACTUAL_USER}" launchctl unsetenv GLOBAL_AGENT_HTTP_PROXY 2>/dev/null || true
+sudo -u "${ACTUAL_USER}" launchctl unsetenv NODE_EXTRA_CA_CERTS 2>/dev/null || true
# Remove binary
rm -f /usr/local/bin/safe-chain
# Remove certificate from system keychain
-CERT_PATH="${USER_HOME}/.safe-chain/certs/ca-cert.pem"
-if [ -f "${CERT_PATH}" ]; then
+CERT_DIR="/usr/local/share/safe-chain/certs"
+if [ -f "${CERT_DIR}/ca-cert.pem" ]; then
echo "Removing certificate from system trust store..."
# Find and delete the certificate by common name
security delete-certificate -c "safe-chain proxy" /Library/Keychains/System.keychain 2>/dev/null || true
fi
+# Remove system-wide configuration and certificates
+rm -rf /usr/local/share/safe-chain
+
# Optionally remove the .safe-chain directory (commented out to preserve user data)
# echo "Remove ~/.safe-chain directory? (y/N)"
# read -r response
diff --git a/installer/scripts/linux_build_installer.sh b/installer/scripts/linux_build_installer.sh
new file mode 100755
index 0000000..8d8d3ea
--- /dev/null
+++ b/installer/scripts/linux_build_installer.sh
@@ -0,0 +1,5 @@
+#!/bin/bash
+echo "=== Building Safe Chain Installer for Linux ==="
+echo "TODO: Implement Linux installer build"
+echo "This is a placeholder script."
+exit 0
diff --git a/installer/scripts/windows_build_installer.bat b/installer/scripts/windows_build_installer.bat
new file mode 100644
index 0000000..709ce23
--- /dev/null
+++ b/installer/scripts/windows_build_installer.bat
@@ -0,0 +1,5 @@
+@echo off
+echo === Building Safe Chain Installer for Windows ===
+echo TODO: Implement Windows installer build
+echo This is a placeholder script.
+exit /b 0
diff --git a/packages/safe-chain/bin/safe-chain.js b/packages/safe-chain/bin/safe-chain.js
index 011e460..4afd2e7 100755
--- a/packages/safe-chain/bin/safe-chain.js
+++ b/packages/safe-chain/bin/safe-chain.js
@@ -36,7 +36,8 @@ if (command === "setup") {
// Pass remaining arguments to runCommand
const runArgs = process.argv.slice(3);
runCommand(runArgs);
-} else if (command === "generate-cert") {
+} else if (command === "_generate-cert") {
+ // Internal command for installer
// Pass remaining arguments to generateCertCommand
const certArgs = process.argv.slice(3);
generateCertCommand(certArgs);
@@ -59,9 +60,7 @@ function writeHelp() {
ui.writeInformation(
`Available commands: ${chalk.cyan("setup")}, ${chalk.cyan(
"teardown"
- )}, ${chalk.cyan("setup-ci")}, ${chalk.cyan("run")}, ${chalk.cyan(
- "generate-cert"
- )}, ${chalk.cyan("help")}, ${chalk.cyan("--version")}`
+ )}, ${chalk.cyan("setup-ci")}, ${chalk.cyan("run")}, ${chalk.cyan("help")}, ${chalk.cyan("--version")}`
);
ui.emptyLine();
ui.writeInformation(
@@ -84,11 +83,6 @@ function writeHelp() {
"safe-chain run"
)}: Run the proxy as a standalone service. Sets system-wide proxy environment variables. Options: --verbose`
);
- ui.writeInformation(
- `- ${chalk.cyan(
- "safe-chain generate-cert"
- )}: Generate CA certificate for MITM proxy. Options: --output `
- );
ui.writeInformation(
`- ${chalk.cyan(
"safe-chain --version"
diff --git a/packages/safe-chain/src/registryProxy/certUtils.js b/packages/safe-chain/src/registryProxy/certUtils.js
index 0838870..add4b68 100644
--- a/packages/safe-chain/src/registryProxy/certUtils.js
+++ b/packages/safe-chain/src/registryProxy/certUtils.js
@@ -3,7 +3,7 @@ import path from "path";
import fs from "fs";
import os from "os";
-const certFolder = path.join(os.homedir(), ".safe-chain", "certs");
+const certFolder = process.env.SAFE_CHAIN_CERT_DIR || path.join(os.homedir(), ".safe-chain", "certs");
const ca = loadCa();
const certCache = new Map();