feat: add nix packaging (#480)
* feat: add Nix flake and module for hermes-workspace deployment * chore: modernize Nix build by migrating to generic nodejs/pnpm packages and adding direnv integration
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,3 +1,8 @@
|
||||
# Nix build outputs
|
||||
result
|
||||
result-*
|
||||
.direnv/
|
||||
|
||||
# Dependencies
|
||||
node_modules
|
||||
.pnp
|
||||
|
||||
61
flake.lock
generated
Normal file
61
flake.lock
generated
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1778869304,
|
||||
"narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "d233902339c02a9c334e7e593de68855ad26c4cb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
119
flake.nix
Normal file
119
flake.nix
Normal file
@@ -0,0 +1,119 @@
|
||||
{
|
||||
description = "Hermes Workspace — desktop workspace for Hermes Agent";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{
|
||||
self,
|
||||
nixpkgs,
|
||||
flake-utils,
|
||||
}:
|
||||
let
|
||||
# -----------------------------------------------------------------------
|
||||
# NixOS module — available on all systems
|
||||
# -----------------------------------------------------------------------
|
||||
nixosModules.default = import ./nix/module.nix;
|
||||
nixosModules.hermes-workspace = nixosModules.default;
|
||||
|
||||
# Overlay that adds hermes-workspace into any nixpkgs instance
|
||||
overlays.default = final: _prev: {
|
||||
hermes-workspace = final.callPackage ./nix/package.nix { };
|
||||
};
|
||||
overlays.hermes-workspace = overlays.default;
|
||||
in
|
||||
# -----------------------------------------------------------------------
|
||||
# Per-system outputs
|
||||
# -----------------------------------------------------------------------
|
||||
flake-utils.lib.eachDefaultSystem (
|
||||
system:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
overlays = [ overlays.default ];
|
||||
};
|
||||
in
|
||||
{
|
||||
# -----------------------------------------------------------------
|
||||
# Packages
|
||||
# -----------------------------------------------------------------
|
||||
packages = {
|
||||
default = pkgs.hermes-workspace;
|
||||
hermes-workspace = pkgs.hermes-workspace;
|
||||
};
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Apps (nix run . or nix run .#hermes-workspace)
|
||||
# -----------------------------------------------------------------
|
||||
apps =
|
||||
let
|
||||
app = {
|
||||
type = "app";
|
||||
program = "${pkgs.hermes-workspace}/bin/hermes-workspace";
|
||||
};
|
||||
in
|
||||
{
|
||||
default = app;
|
||||
hermes-workspace = app;
|
||||
};
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Dev shell (nix develop)
|
||||
# -----------------------------------------------------------------
|
||||
devShells.default = pkgs.mkShell {
|
||||
name = "hermes-workspace-dev";
|
||||
|
||||
packages = with pkgs; [
|
||||
# Node / JS toolchain
|
||||
nodejs
|
||||
pnpm
|
||||
typescript
|
||||
|
||||
# Python for pty-helper and build scripts
|
||||
python3
|
||||
|
||||
# Nix tooling
|
||||
nil # Nix LSP
|
||||
nixfmt-rfc-style
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
echo ""
|
||||
echo " 🚀 hermes-workspace dev shell"
|
||||
echo " node $(node --version)"
|
||||
echo " pnpm $(pnpm --version)"
|
||||
echo " python $(python3 --version)"
|
||||
echo ""
|
||||
echo " Quick start:"
|
||||
echo " pnpm install"
|
||||
echo " pnpm dev # Vite dev server on :3000"
|
||||
echo " pnpm build # Production build → dist/"
|
||||
echo " node server-entry.js # Serve production build"
|
||||
echo ""
|
||||
'';
|
||||
};
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Formatter (nix fmt)
|
||||
# -----------------------------------------------------------------
|
||||
formatter = pkgs.nixfmt-rfc-style;
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Checks (nix flake check)
|
||||
# -----------------------------------------------------------------
|
||||
checks = {
|
||||
# Verify the package evaluates without building it
|
||||
package-eval = pkgs.runCommand "hermes-workspace-pkg-eval" { } ''
|
||||
echo "Package evaluated: ${pkgs.hermes-workspace.name}" > $out
|
||||
'';
|
||||
};
|
||||
}
|
||||
)
|
||||
// {
|
||||
# Expose module + overlay at the top level (system-agnostic)
|
||||
inherit nixosModules overlays;
|
||||
};
|
||||
}
|
||||
241
nix/module.nix
Normal file
241
nix/module.nix
Normal file
@@ -0,0 +1,241 @@
|
||||
# NixOS module: services.hermes-workspace
|
||||
#
|
||||
# Runs the hermes-workspace web server as a systemd service.
|
||||
# The companion hermes-agent gateway must be running separately
|
||||
# (see https://github.com/NousResearch/hermes-agent).
|
||||
#
|
||||
# Minimal NixOS configuration example:
|
||||
#
|
||||
# services.hermes-workspace = {
|
||||
# enable = true;
|
||||
# hermesApiUrl = "http://127.0.0.1:8642";
|
||||
# # For remote access, set a password and open the port:
|
||||
# host = "0.0.0.0";
|
||||
# passwordFile = config.sops.secrets."hermes-workspace-password".path;
|
||||
# };
|
||||
# networking.firewall.allowedTCPPorts = [ 3000 ];
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
|
||||
let
|
||||
cfg = config.services.hermes-workspace;
|
||||
inherit (lib)
|
||||
mkEnableOption
|
||||
mkIf
|
||||
mkOption
|
||||
mkPackageOption
|
||||
types
|
||||
;
|
||||
in
|
||||
{
|
||||
options.services.hermes-workspace = {
|
||||
enable = mkEnableOption "Hermes Workspace — web UI for Hermes Agent";
|
||||
|
||||
package = mkPackageOption pkgs "hermes-workspace" { };
|
||||
|
||||
port = mkOption {
|
||||
type = types.port;
|
||||
default = 3000;
|
||||
description = "TCP port the workspace server listens on.";
|
||||
};
|
||||
|
||||
host = mkOption {
|
||||
type = types.str;
|
||||
default = "127.0.0.1";
|
||||
description = ''
|
||||
Address to bind the HTTP server to.
|
||||
Set to "0.0.0.0" to expose on all interfaces (requires passwordFile
|
||||
or allowInsecureRemote = true).
|
||||
'';
|
||||
};
|
||||
|
||||
hermesApiUrl = mkOption {
|
||||
type = types.str;
|
||||
default = "http://127.0.0.1:8642";
|
||||
description = ''
|
||||
URL of the Hermes Agent gateway HTTP API.
|
||||
Requires API_SERVER_ENABLED=true in the gateway's environment.
|
||||
'';
|
||||
};
|
||||
|
||||
hermesDashboardUrl = mkOption {
|
||||
type = types.str;
|
||||
default = "http://127.0.0.1:9119";
|
||||
description = "URL of the Hermes Agent dashboard.";
|
||||
};
|
||||
|
||||
passwordFile = mkOption {
|
||||
type = types.nullOr types.path;
|
||||
default = null;
|
||||
description = ''
|
||||
Path to a file whose first line is the workspace session password.
|
||||
Required when host is not a loopback address.
|
||||
Use a secrets manager (sops-nix, agenix, etc.) to manage this file.
|
||||
'';
|
||||
};
|
||||
|
||||
cookieSecure = mkOption {
|
||||
type = types.nullOr types.bool;
|
||||
default = null;
|
||||
description = ''
|
||||
Override the Secure flag on session cookies.
|
||||
null means "auto" (enabled in production mode).
|
||||
Set to false for plain-HTTP LAN deployments behind a proxy.
|
||||
'';
|
||||
};
|
||||
|
||||
trustProxy = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Trust X-Forwarded-For / X-Real-IP headers from a reverse proxy.
|
||||
Only enable when the server is behind a trusted proxy (Nginx, Traefik, etc.).
|
||||
'';
|
||||
};
|
||||
|
||||
allowInsecureRemote = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Allow binding to non-loopback addresses without a password.
|
||||
NOT recommended — only use behind a custom auth layer.
|
||||
'';
|
||||
};
|
||||
|
||||
hermesWorldEnabled = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
description = "Show the HermesWorld multiplayer link in the sidebar.";
|
||||
};
|
||||
|
||||
extraEnvironment = mkOption {
|
||||
type = types.attrsOf types.str;
|
||||
default = { };
|
||||
example = {
|
||||
STREAM_ACCEPTED_TIMEOUT_MS = "120000";
|
||||
VITE_PLAYGROUND_WS_URL = "wss://my-hub.example.com/playground";
|
||||
};
|
||||
description = "Extra environment variables passed to the service.";
|
||||
};
|
||||
|
||||
environmentFile = mkOption {
|
||||
type = types.nullOr types.path;
|
||||
default = null;
|
||||
description = ''
|
||||
Path to a file containing additional environment variables
|
||||
(KEY=value, one per line). Useful for secrets not covered by
|
||||
the structured options above.
|
||||
'';
|
||||
};
|
||||
|
||||
user = mkOption {
|
||||
type = types.str;
|
||||
default = "hermes-workspace";
|
||||
description = "System user to run the service as.";
|
||||
};
|
||||
|
||||
group = mkOption {
|
||||
type = types.str;
|
||||
default = "hermes-workspace";
|
||||
description = "System group to run the service as.";
|
||||
};
|
||||
|
||||
dataDir = mkOption {
|
||||
type = types.path;
|
||||
default = "/var/lib/hermes-workspace";
|
||||
description = ''
|
||||
State directory for the workspace (sessions, runtime data).
|
||||
The service user must have write access.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
users.users.${cfg.user} = lib.mkDefault {
|
||||
isSystemUser = true;
|
||||
group = cfg.group;
|
||||
home = cfg.dataDir;
|
||||
createHome = true;
|
||||
description = "Hermes Workspace service user";
|
||||
};
|
||||
|
||||
users.groups.${cfg.group} = lib.mkDefault { };
|
||||
|
||||
systemd.services.hermes-workspace = {
|
||||
description = "Hermes Workspace Web Server";
|
||||
documentation = [ "https://github.com/outsourc-e/hermes-workspace" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network.target" ];
|
||||
|
||||
environment =
|
||||
{
|
||||
NODE_ENV = "production";
|
||||
PORT = toString cfg.port;
|
||||
HOST = cfg.host;
|
||||
HERMES_API_URL = cfg.hermesApiUrl;
|
||||
HERMES_DASHBOARD_URL = cfg.hermesDashboardUrl;
|
||||
VITE_HERMESWORLD_ENABLED = if cfg.hermesWorldEnabled then "1" else "0";
|
||||
TRUST_PROXY = if cfg.trustProxy then "1" else "0";
|
||||
HERMES_ALLOW_INSECURE_REMOTE = if cfg.allowInsecureRemote then "1" else "0";
|
||||
# Point HOME to the data dir so session files land there
|
||||
HOME = cfg.dataDir;
|
||||
}
|
||||
// lib.optionalAttrs (cfg.cookieSecure != null) {
|
||||
COOKIE_SECURE = if cfg.cookieSecure then "1" else "0";
|
||||
}
|
||||
// cfg.extraEnvironment;
|
||||
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
User = cfg.user;
|
||||
Group = cfg.group;
|
||||
WorkingDirectory = cfg.dataDir;
|
||||
|
||||
ExecStart = "${lib.getExe cfg.package}";
|
||||
|
||||
# Load the password file as an env file when specified.
|
||||
# The file must contain: HERMES_PASSWORD=<value>
|
||||
EnvironmentFile = lib.optional (cfg.passwordFile != null) cfg.passwordFile
|
||||
++ lib.optional (cfg.environmentFile != null) cfg.environmentFile;
|
||||
|
||||
# Restart on failure with backoff
|
||||
Restart = "on-failure";
|
||||
RestartSec = "5s";
|
||||
StartLimitIntervalSec = "120";
|
||||
StartLimitBurst = "5";
|
||||
|
||||
# Runtime directories
|
||||
RuntimeDirectory = "hermes-workspace";
|
||||
StateDirectory = lib.removePrefix "/var/lib/" cfg.dataDir;
|
||||
LogsDirectory = "hermes-workspace";
|
||||
|
||||
# Security hardening (balanced against PTY + terminal needs)
|
||||
NoNewPrivileges = true;
|
||||
PrivateTmp = true;
|
||||
ProtectSystem = "strict";
|
||||
ProtectHome = true;
|
||||
ReadWritePaths = [ cfg.dataDir ];
|
||||
# PTY helper needs /dev/ptmx and /dev/pts
|
||||
PrivateDevices = false;
|
||||
DeviceAllow = [
|
||||
"/dev/ptmx rw"
|
||||
"char-pts rw"
|
||||
];
|
||||
ProtectKernelTunables = true;
|
||||
ProtectControlGroups = true;
|
||||
ProtectKernelModules = true;
|
||||
RestrictNamespaces = true;
|
||||
RestrictRealtime = true;
|
||||
RestrictSUIDSGID = true;
|
||||
LockPersonality = true;
|
||||
MemoryDenyWriteExecute = false; # Node.js JIT requires this off
|
||||
SystemCallFilter = "@system-service";
|
||||
SystemCallErrorNumber = "EPERM";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
106
nix/package.nix
Normal file
106
nix/package.nix
Normal file
@@ -0,0 +1,106 @@
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
nodejs,
|
||||
pnpm,
|
||||
fetchPnpmDeps,
|
||||
pnpmConfigHook,
|
||||
python3,
|
||||
makeWrapper,
|
||||
}:
|
||||
|
||||
stdenv.mkDerivation (finalAttrs: {
|
||||
pname = "hermes-workspace";
|
||||
version = "2.3.0";
|
||||
|
||||
src = lib.cleanSourceWith {
|
||||
src = ../.;
|
||||
filter = name: type:
|
||||
let
|
||||
baseName = builtins.baseNameOf name;
|
||||
relPath = lib.removePrefix (toString ../.) name;
|
||||
in
|
||||
# Exclude dirs that don't affect the build
|
||||
!(lib.hasPrefix "/.git" relPath)
|
||||
&& !(lib.hasPrefix "/node_modules" relPath)
|
||||
&& !(lib.hasPrefix "/dist" relPath)
|
||||
&& !(lib.hasPrefix "/.output" relPath)
|
||||
&& !(lib.hasPrefix "/.tanstack" relPath)
|
||||
&& !(lib.hasPrefix "/.vinxi" relPath)
|
||||
&& !(lib.hasPrefix "/release" relPath)
|
||||
&& !(lib.hasPrefix "/electron/server-bundle.cjs" relPath)
|
||||
&& !(lib.hasPrefix "/memory" relPath)
|
||||
&& !(lib.hasPrefix "/screenshots" relPath)
|
||||
&& baseName != ".env"
|
||||
&& baseName != ".env.local";
|
||||
};
|
||||
|
||||
pnpmDeps = fetchPnpmDeps {
|
||||
inherit (finalAttrs) pname version src;
|
||||
pnpm = pnpm; # Ensure fetcher uses the same pnpm binary as the build
|
||||
fetcherVersion = 3;
|
||||
hash = "sha256-cgK1/KQkA9zOb1Zn5/OjV9qTXQEIVBaTWldbCbdRULs=";
|
||||
};
|
||||
|
||||
nativeBuildInputs = [
|
||||
nodejs
|
||||
pnpm # provides the pnpm binary used by pnpmConfigHook
|
||||
pnpmConfigHook
|
||||
makeWrapper
|
||||
];
|
||||
|
||||
buildInputs = [ python3 ];
|
||||
|
||||
# Give the build plenty of memory — same as the package.json script
|
||||
NODE_OPTIONS = "--max-old-space-size=2048";
|
||||
|
||||
# Vite / TanStack Start require NODE_ENV=production for the SSR build so
|
||||
# runtime env vars aren't inlined into client bundles.
|
||||
NODE_ENV = "production";
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
pnpm run build
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
local appDir="$out/lib/hermes-workspace"
|
||||
mkdir -p "$appDir"
|
||||
|
||||
# Copy build artefacts and runtime sources
|
||||
cp -r dist "$appDir/"
|
||||
cp -r node_modules "$appDir/"
|
||||
cp -r skills "$appDir/"
|
||||
cp package.json server-entry.js "$appDir/"
|
||||
|
||||
# pty-helper.py: Vite's copy-pty-helper plugin writes it during build
|
||||
# but we also ensure it's present here as a belt-and-suspenders measure.
|
||||
local ptyHelper="$appDir/dist/server/assets/pty-helper.py"
|
||||
if [ ! -f "$ptyHelper" ]; then
|
||||
mkdir -p "$(dirname "$ptyHelper")"
|
||||
cp src/server/pty-helper.py "$ptyHelper"
|
||||
fi
|
||||
|
||||
# Create a wrapper script so the binary lands in $out/bin
|
||||
mkdir -p "$out/bin"
|
||||
makeWrapper "${nodejs}/bin/node" "$out/bin/hermes-workspace" \
|
||||
--add-flags "--max-old-space-size=2048" \
|
||||
--add-flags "$appDir/server-entry.js" \
|
||||
--set NODE_ENV "production" \
|
||||
--prefix PATH : "${python3}/bin"
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "Desktop workspace for Hermes Agent — chat, orchestration, and multi-agent coding pipelines";
|
||||
homepage = "https://github.com/outsourc-e/hermes-workspace";
|
||||
license = lib.licenses.mit;
|
||||
maintainers = [ ];
|
||||
platforms = lib.platforms.linux ++ lib.platforms.darwin;
|
||||
mainProgram = "hermes-workspace";
|
||||
};
|
||||
})
|
||||
Reference in New Issue
Block a user