diff --git a/.github/workflows/fix-nix-hash.yml b/.github/workflows/fix-nix-hash.yml new file mode 100644 index 000000000..939216a96 --- /dev/null +++ b/.github/workflows/fix-nix-hash.yml @@ -0,0 +1,41 @@ +name: Fix Nix hash + +on: + pull_request: + paths: + - 'package.json' + - 'package-lock.json' + +permissions: + contents: write + +jobs: + fix-nix-hash: + runs-on: ubuntu-latest + if: github.actor == 'dependabot[bot]' + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - uses: cachix/install-nix-action@v31 + with: + nix_path: nixpkgs=channel:nixos-unstable + + - name: Fix lockfile and update hash + run: ./scripts/update-nix.sh + + - name: Commit changes + run: | + git diff --quiet package-lock.json nix/package.nix && exit 0 + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add package-lock.json nix/package.nix + git commit -m "fix: update lockfile signatures and Nix hash" + git push diff --git a/.github/workflows/nix-build.yml b/.github/workflows/nix-build.yml new file mode 100644 index 000000000..aab287463 --- /dev/null +++ b/.github/workflows/nix-build.yml @@ -0,0 +1,59 @@ +name: Nix Build + +on: + push: + branches: [main] + paths: + - 'nix/**' + - 'flake.nix' + - 'flake.lock' + - 'package.json' + - 'package-lock.json' + - 'packages/highlight/**' + - 'packages/server/**' + - 'packages/relay/**' + - 'packages/cli/**' + pull_request: + branches: [main] + paths: + - 'nix/**' + - 'flake.nix' + - 'flake.lock' + - 'package.json' + - 'package-lock.json' + - 'packages/highlight/**' + - 'packages/server/**' + - 'packages/relay/**' + - 'packages/cli/**' + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v4 + + - uses: cachix/install-nix-action@v31 + with: + nix_path: nixpkgs=channel:nixos-unstable + + - name: Build Nix package + run: nix build .#default -o result + + - name: Verify lockfile is complete + # npm silently omits resolved/integrity fields in workspace monorepos. + # Nix needs them for offline builds. See https://github.com/npm/cli/issues/4460 + run: | + node scripts/fix-lockfile.mjs package-lock.json + git diff --exit-code package-lock.json || { + echo "ERROR: package-lock.json has missing resolved/integrity fields." + echo "This is a known npm bug: https://github.com/npm/cli/issues/4460" + echo "Run 'node scripts/fix-lockfile.mjs' and commit the result." + exit 1 + } + + - name: Check npmDepsHash is up to date + run: ./scripts/update-nix.sh --check diff --git a/flake.lock b/flake.lock new file mode 100644 index 000000000..3e389405e --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1772963539, + "narHash": "sha256-9jVDGZnvCckTGdYT53d/EfznygLskyLQXYwJLKMPsZs=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "9dcb002ca1690658be4a04645215baea8b95f31d", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 000000000..f9e194318 --- /dev/null +++ b/flake.nix @@ -0,0 +1,59 @@ +{ + description = "Paseo - self-hosted daemon for AI coding agents"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + }; + + outputs = + { + self, + nixpkgs, + }: + let + supportedSystems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + forAllSystems = nixpkgs.lib.genAttrs supportedSystems; + pkgsFor = system: import nixpkgs { inherit system; }; + in + { + packages = forAllSystems ( + system: + let + pkgs = pkgsFor system; + paseo = pkgs.callPackage ./nix/package.nix { }; + in + { + default = paseo; + paseo = paseo; + } + ); + + nixosModules.default = self.nixosModules.paseo; + nixosModules.paseo = + { pkgs, lib, ... }: + { + imports = [ ./nix/module.nix ]; + services.paseo.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.default; + }; + + devShells = forAllSystems ( + system: + let + pkgs = pkgsFor system; + in + { + default = pkgs.mkShell { + packages = [ + pkgs.nodejs_22 + pkgs.python3 + ]; + }; + } + ); + }; +} diff --git a/nix/module.nix b/nix/module.nix new file mode 100644 index 000000000..72a387614 --- /dev/null +++ b/nix/module.nix @@ -0,0 +1,172 @@ +{ + config, + lib, + pkgs, + ... +}: + +let + cfg = config.services.paseo; +in +{ + options.services.paseo = { + enable = lib.mkEnableOption "Paseo, a self-hosted daemon for AI coding agents"; + + package = lib.mkPackageOption pkgs "paseo" { }; + + user = lib.mkOption { + type = lib.types.str; + default = "paseo"; + description = "User account under which Paseo runs."; + }; + + group = lib.mkOption { + type = lib.types.str; + default = "paseo"; + description = "Group under which Paseo runs."; + }; + + dataDir = lib.mkOption { + type = lib.types.str; + default = + if cfg.user == "paseo" + then "/var/lib/paseo" + else "/home/${cfg.user}/.paseo"; + defaultText = lib.literalExpression '' + if cfg.user == "paseo" + then "/var/lib/paseo" + else "/home/''${cfg.user}/.paseo" + ''; + description = "Directory for Paseo state (PASEO_HOME). Stores agent data, config, and logs."; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 6767; + description = "Port for the Paseo daemon to listen on."; + }; + + listenAddress = lib.mkOption { + type = lib.types.str; + default = "127.0.0.1"; + description = "Address for the Paseo daemon to bind to."; + }; + + openFirewall = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Whether to open the firewall for the Paseo daemon port."; + }; + + allowedHosts = lib.mkOption { + type = lib.types.either (lib.types.enum [ true ]) (lib.types.listOf lib.types.str); + default = [ ]; + example = [ ".example.com" "myhost.local" ]; + description = '' + Hosts allowed to connect to the Paseo daemon (DNS rebinding protection). + Localhost and IP addresses are always allowed by default. + + Use a leading dot to match a domain and all its subdomains + (e.g. `".example.com"` matches `example.com` and `foo.example.com`). + + Set to `true` to allow any host (not recommended). + ''; + }; + + relay = { + enable = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Whether to enable the relay connection for remote access via app.paseo.sh."; + }; + }; + + inheritUserEnvironment = lib.mkOption { + type = lib.types.bool; + default = cfg.user != "paseo"; + defaultText = lib.literalExpression ''cfg.user != "paseo"''; + description = '' + Whether to include the user's profile PATH in the service environment. + + When Paseo runs as a real user (not the default system user), AI agents + need access to the user's tools (git, ssh, etc.). This adds the user's + NixOS profile and system paths so agents can use them without manually + setting PATH. + + Enabled by default when `user` is set to a non-default value. + ''; + }; + + environment = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = { }; + example = lib.literalExpression '' + { + PASEO_RELAY_ENDPOINT = "relay.paseo.sh:443"; + } + ''; + description = "Extra environment variables for the Paseo daemon."; + }; + }; + + config = lib.mkIf cfg.enable { + users.users.${cfg.user} = lib.mkIf (cfg.user == "paseo") { + isSystemUser = true; + group = cfg.group; + home = cfg.dataDir; + }; + + users.groups.${cfg.group} = lib.mkIf (cfg.group == "paseo") { }; + + systemd.tmpfiles.rules = [ + "d ${cfg.dataDir} 0700 ${cfg.user} ${cfg.group} - -" + ]; + + systemd.services.paseo = { + description = "Paseo - self-hosted daemon for AI coding agents"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + + environment = { + NODE_ENV = "production"; + PASEO_HOME = cfg.dataDir; + PASEO_LISTEN = "${cfg.listenAddress}:${toString cfg.port}"; + } // lib.optionalAttrs cfg.inheritUserEnvironment { + # mkForce overrides the default PATH from NixOS's systemd module (which + # only includes store paths for coreutils/grep/sed/systemd). Our PATH + # includes /run/current-system/sw/bin which is a superset of those. + PATH = lib.mkForce (lib.concatStringsSep ":" [ + "/etc/profiles/per-user/${cfg.user}/bin" + "/run/current-system/sw/bin" + "/run/wrappers/bin" + "/nix/var/nix/profiles/default/bin" + ]); + } // lib.optionalAttrs (cfg.allowedHosts == true) { + PASEO_ALLOWED_HOSTS = "true"; + } // lib.optionalAttrs (lib.isList cfg.allowedHosts && cfg.allowedHosts != [ ]) { + PASEO_ALLOWED_HOSTS = lib.concatStringsSep "," cfg.allowedHosts; + } // cfg.environment; + + serviceConfig = { + Type = "simple"; + User = cfg.user; + Group = cfg.group; + + ExecStart = + "${cfg.package}/bin/paseo-server" + + lib.optionalString (!cfg.relay.enable) " --no-relay"; + + Restart = "on-failure"; + RestartSec = 5; + + # Graceful shutdown (server handles SIGTERM with a 10s timeout) + KillSignal = "SIGTERM"; + TimeoutStopSec = 15; + }; + }; + + environment.systemPackages = [ cfg.package ]; + + networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ]; + }; +} diff --git a/nix/package.nix b/nix/package.nix new file mode 100644 index 000000000..8341a5659 --- /dev/null +++ b/nix/package.nix @@ -0,0 +1,144 @@ +{ + lib, + stdenv, + buildNpmPackage, + nodejs_22, + python3, + makeWrapper, + # node-pty needs libuv headers on Linux + libuv, +}: + +buildNpmPackage rec { + pname = "paseo"; + version = (builtins.fromJSON (builtins.readFile ../package.json)).version; + + src = lib.cleanSourceWith { + src = ./..; + filter = path: type: + let + baseName = builtins.baseNameOf path; + relPath = lib.removePrefix (toString ./..) path; + in + # Exclude non-daemon workspace contents (keep package.json for workspace resolution) + !(lib.hasPrefix "/packages/app/src" relPath) + && !(lib.hasPrefix "/packages/app/assets" relPath) + && !(lib.hasPrefix "/packages/app/android" relPath) + && !(lib.hasPrefix "/packages/app/ios" relPath) + && !(lib.hasPrefix "/packages/website/src" relPath) + && !(lib.hasPrefix "/packages/website/public" relPath) + && !(lib.hasPrefix "/packages/desktop/src" relPath) + && !(lib.hasPrefix "/packages/desktop/src-tauri" relPath) + # Exclude test fixtures and debug files + && !(lib.hasSuffix ".test.ts" baseName) + && !(lib.hasSuffix ".e2e.test.ts" baseName) + && baseName != "node_modules" + && baseName != ".git" + && baseName != ".paseo" + && baseName != ".DS_Store"; + }; + + nodejs = nodejs_22; + + # To update: run `nix build` with lib.fakeHash, copy the `got:` hash. + # CI auto-updates this when package-lock.json changes (see .github/workflows/). + npmDepsHash = "sha256-gOwvUBvem1SxDMypaexz6RaHRm2xFmUT9iwOW2ErEAM="; + + # Prevent onnxruntime-node's install script from running during automatic + # npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox). + # We manually rebuild only node-pty in buildPhase. + npmRebuildFlags = [ "--ignore-scripts" ]; + + nativeBuildInputs = [ + python3 # for node-gyp (node-pty compilation) + makeWrapper + ]; + + buildInputs = lib.optionals stdenv.hostPlatform.isLinux [ + libuv + ]; + + # Don't use the default npm build hook — we need a custom build sequence + dontNpmBuild = true; + + buildPhase = '' + runHook preBuild + + # Rebuild only node-pty (native addon for terminal emulation). + # Speech-related native modules (sherpa-onnx, onnxruntime-node) are + # intentionally left unbuilt — they're lazily loaded and gracefully + # degrade when unavailable. + npm rebuild node-pty + + # Build all daemon packages in dependency order (defined in package.json) + npm run build:daemon + + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + + mkdir -p $out/lib/paseo + + # Copy root package metadata + cp package.json $out/lib/paseo/ + + # Copy node_modules (preserving workspace symlinks) + cp -a node_modules $out/lib/paseo/ + + # Auto-detect which @getpaseo/* packages were built by build:daemon + # (they'll have a dist/ directory). Copy those and remove the rest. + for link in $out/lib/paseo/node_modules/@getpaseo/*; do + name=$(basename "$link") + if [ -d "packages/$name/dist" ]; then + mkdir -p "$out/lib/paseo/packages/$name" + cp "packages/$name/package.json" "$out/lib/paseo/packages/$name/" + cp -a "packages/$name/dist" "$out/lib/paseo/packages/$name/" + if [ -d "packages/$name/node_modules" ]; then + cp -a "packages/$name/node_modules" "$out/lib/paseo/packages/$name/" + fi + else + rm -f "$link" + fi + done + + # Copy CLI bin entry + mkdir -p $out/lib/paseo/packages/cli/bin + cp packages/cli/bin/paseo $out/lib/paseo/packages/cli/bin/ + + # Copy extra server files referenced at runtime + for f in agent-prompt.md .env.example; do + if [ -f packages/server/$f ]; then + cp packages/server/$f $out/lib/paseo/packages/server/ + fi + done + + # Copy server scripts (daemon-runner, supervisor) needed by CLI + if [ -d packages/server/dist/scripts ]; then + mkdir -p $out/lib/paseo/packages/server/dist/scripts + cp -a packages/server/dist/scripts/* $out/lib/paseo/packages/server/dist/scripts/ + fi + + # Create wrapper for the server entry point (for systemd / direct use) + mkdir -p $out/bin + makeWrapper ${nodejs}/bin/node $out/bin/paseo-server \ + --add-flags "$out/lib/paseo/packages/server/dist/server/server/index.js" \ + --set NODE_ENV production + + # Create wrapper for the CLI + makeWrapper ${nodejs}/bin/node $out/bin/paseo \ + --add-flags "$out/lib/paseo/packages/cli/dist/index.js" \ + --set NODE_PATH "$out/lib/paseo/node_modules" + + runHook postInstall + ''; + + meta = { + description = "Self-hosted daemon for Claude Code, Codex, and OpenCode"; + homepage = "https://github.com/getpaseo/paseo"; + license = lib.licenses.agpl3Plus; + mainProgram = "paseo"; + platforms = lib.platforms.linux ++ lib.platforms.darwin; + }; +} diff --git a/package-lock.json b/package-lock.json index 61cd20437..86262578b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34942,7 +34942,9 @@ "expo": "*", "react": "*", "react-native": "*" - } + }, + "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.7.tgz", + "integrity": "sha512-zvlfFV+wB2QQrQnHWlo0EKHAkdi2tycLtE+EXFUWTPZYkgu1XcH+aiKfd4ul7Z0SDF+1IuwoiW9AA9eO35aj3Q==" }, "packages/app/node_modules/react-native-nitro-modules": { "version": "0.33.8", @@ -34959,7 +34961,9 @@ "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" - } + }, + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" }, "packages/cli": { "name": "@getpaseo/cli", @@ -34993,14 +34997,18 @@ }, "funding": { "url": "https://github.com/chalk/chalk?sponsor=1" - } + }, + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==" }, "packages/cli/node_modules/commander": { "version": "12.1.0", "license": "MIT", "engines": { "node": ">=18" - } + }, + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==" }, "packages/desktop": { "name": "@getpaseo/desktop", @@ -35351,7 +35359,9 @@ }, "engines": { "node": ">=18" - } + }, + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.20.1.tgz", + "integrity": "sha512-j/P+yuxXfgxb+mW7OEoRCM3G47zCTDqUPivJo/VzpjbG8I9csTXtOprCf5FfOfHK4whOJny0aHuBEON+kS7CCA==" }, "packages/server/node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { "version": "6.12.6", @@ -35365,7 +35375,9 @@ "funding": { "type": "github", "url": "https://github.com/sponsors/epoberezkin" - } + }, + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==" }, "packages/server/node_modules/@modelcontextprotocol/sdk/node_modules/express": { "version": "5.1.0", @@ -35405,7 +35417,9 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/express" - } + }, + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==" }, "packages/server/node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { "version": "0.4.1", @@ -35422,7 +35436,9 @@ }, "engines": { "node": ">= 0.6" - } + }, + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==" }, "packages/server/node_modules/ajv": { "version": "8.17.1", @@ -35436,7 +35452,9 @@ "funding": { "type": "github", "url": "https://github.com/sponsors/epoberezkin" - } + }, + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==" }, "packages/server/node_modules/ansi-regex": { "version": "6.2.2", @@ -35446,7 +35464,9 @@ }, "funding": { "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } + }, + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==" }, "packages/server/node_modules/body-parser": { "version": "2.2.0", @@ -35464,7 +35484,9 @@ }, "engines": { "node": ">=18" - } + }, + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==" }, "packages/server/node_modules/content-disposition": { "version": "1.0.0", @@ -35474,14 +35496,18 @@ }, "engines": { "node": ">= 0.6" - } + }, + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==" }, "packages/server/node_modules/cookie-signature": { "version": "1.2.2", "license": "MIT", "engines": { "node": ">=6.6.0" - } + }, + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==" }, "packages/server/node_modules/finalhandler": { "version": "2.1.0", @@ -35496,21 +35522,27 @@ }, "engines": { "node": ">= 0.8" - } + }, + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==" }, "packages/server/node_modules/fresh": { "version": "2.0.0", "license": "MIT", "engines": { "node": ">= 0.8" - } + }, + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==" }, "packages/server/node_modules/media-typer": { "version": "1.1.0", "license": "MIT", "engines": { "node": ">= 0.8" - } + }, + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==" }, "packages/server/node_modules/merge-descriptors": { "version": "2.0.0", @@ -35520,7 +35552,9 @@ }, "funding": { "url": "https://github.com/sponsors/sindresorhus" - } + }, + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==" }, "packages/server/node_modules/mime-types": { "version": "3.0.1", @@ -35530,14 +35564,18 @@ }, "engines": { "node": ">= 0.6" - } + }, + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==" }, "packages/server/node_modules/negotiator": { "version": "1.0.0", "license": "MIT", "engines": { "node": ">= 0.6" - } + }, + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==" }, "packages/server/node_modules/raw-body": { "version": "3.0.2", @@ -35588,7 +35626,9 @@ }, "engines": { "node": ">= 18" - } + }, + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==" }, "packages/server/node_modules/serve-static": { "version": "2.2.0", @@ -35601,7 +35641,9 @@ }, "engines": { "node": ">= 18" - } + }, + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==" }, "packages/server/node_modules/strip-ansi": { "version": "7.1.2", @@ -35614,7 +35656,9 @@ }, "funding": { "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } + }, + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==" }, "packages/server/node_modules/type-is": { "version": "2.0.1", @@ -35626,14 +35670,18 @@ }, "engines": { "node": ">= 0.6" - } + }, + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==" }, "packages/server/node_modules/zod": { "version": "3.25.76", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" - } + }, + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" }, "packages/website": { "name": "@getpaseo/website", @@ -35667,7 +35715,9 @@ "license": "MIT", "dependencies": { "undici-types": "~6.21.0" - } + }, + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.6.tgz", + "integrity": "sha512-qm+G8HuG6hOHQigsi7VGuLjUVu6TtBo/F05zvX04Mw2uCg9Dv0Qxy3Qw7j41SidlTcl5D/5yg0SEZqOB+EqZnQ==" }, "packages/website/node_modules/react": { "version": "19.2.4", diff --git a/scripts/fix-lockfile.mjs b/scripts/fix-lockfile.mjs new file mode 100644 index 000000000..584ced79a --- /dev/null +++ b/scripts/fix-lockfile.mjs @@ -0,0 +1,76 @@ +#!/usr/bin/env node +// Workaround for https://github.com/npm/cli/issues/4460 +// +// npm silently omits `resolved` and `integrity` fields from some +// package-lock.json entries in workspace monorepos (especially for +// workspace-hoisted packages). npm acknowledged this as a bug in 2022 +// but has never shipped a fix. +// +// This is harmless for regular `npm ci`, but breaks offline installers +// like Nix that need every entry to have a resolved URL + integrity hash +// so they can pre-fetch all tarballs in a sandbox with no network access. +// +// This script finds incomplete entries and fills them in using `npm view`. +// It's idempotent — running it on an already-complete lockfile is a no-op. +// +// See also: https://github.com/npm/cli/issues/4263 +// https://github.com/npm/cli/issues/6301 +// +// Usage: +// node scripts/fix-lockfile.mjs +// node scripts/fix-lockfile.mjs path/to/package-lock.json + +import fs from "fs"; +import { execSync } from "child_process"; + +const lockPath = process.argv[2] || "package-lock.json"; +const lock = JSON.parse(fs.readFileSync(lockPath, "utf8")); + +// Collect workspace package roots (local packages, not from npm) +const workspaceRoots = new Set(); +for (const [key, val] of Object.entries(lock.packages || {})) { + if (val.link) { + workspaceRoots.add(val.resolved || key); + } +} + +let fixed = 0; + +for (const [key, val] of Object.entries(lock.packages || {})) { + if ( + !key || // root package + key.startsWith("node_modules/") || // top-level (already has resolved) + val.link || // workspace link entry + (val.resolved && val.integrity) || // already complete + !val.version || // no version to look up + workspaceRoots.has(key) // workspace package root (local, not on npm) + ) + continue; + + const pkgName = key.replace(/.*node_modules\//, ""); + const version = val.version; + + try { + const info = JSON.parse( + execSync(`npm view ${pkgName}@${version} --json dist`, { + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + }) + ); + if (info.tarball && info.integrity) { + val.resolved = info.tarball; + val.integrity = info.integrity; + fixed++; + } + } catch { + console.error(`Warning: could not fetch info for ${pkgName}@${version}`); + } +} + +fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2) + "\n"); + +if (fixed > 0) { + console.log(`Fixed ${fixed} lockfile entries with missing resolved/integrity`); +} else { + console.log("Lockfile is already complete"); +} diff --git a/scripts/update-nix.sh b/scripts/update-nix.sh new file mode 100755 index 000000000..387007dfb --- /dev/null +++ b/scripts/update-nix.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Fix workspace-local lockfile entries and update the Nix dependency hash. +# Requires: node, npm, nix +# +# Usage: +# ./scripts/update-nix.sh # fix lockfile + update hash +# ./scripts/update-nix.sh --check # verify everything is up to date (CI mode) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +LOCK_FILE="$ROOT_DIR/package-lock.json" +PACKAGE_NIX="$ROOT_DIR/nix/package.nix" + +CHECK_MODE=false +if [[ "${1:-}" == "--check" ]]; then + CHECK_MODE=true +fi + +# 1. Fix lockfile (add resolved/integrity for workspace-local entries) +# Workaround for https://github.com/npm/cli/issues/4460 +echo "Fixing lockfile..." +node "$SCRIPT_DIR/fix-lockfile.mjs" "$LOCK_FILE" + +# 2. Prefetch deps and compute hash +echo "Prefetching npm dependencies..." + +# Resolve prefetch-npm-deps from the same nixpkgs pinned in flake.lock +NIXPKGS_URL="$(node -p " + const l = JSON.parse(require('fs').readFileSync('$ROOT_DIR/flake.lock', 'utf8')); + const n = l.nodes.nixpkgs.locked; + 'github:' + n.owner + '/' + n.repo + '/' + n.rev; +")" + +STDERR_LOG="$(mktemp)" +trap "rm -f '$STDERR_LOG'" EXIT + +if ! NEW_HASH="$(nix shell "${NIXPKGS_URL}#prefetch-npm-deps" -c prefetch-npm-deps "$LOCK_FILE" 2>"$STDERR_LOG")"; then + echo "ERROR: prefetch-npm-deps failed:" >&2 + tail -20 "$STDERR_LOG" >&2 + exit 1 +fi +echo "Computed hash: $NEW_HASH" + +# 3. Read current hash +CURRENT_HASH="$(grep 'npmDepsHash' "$PACKAGE_NIX" | sed 's/.*"\(.*\)".*/\1/')" + +if [[ "$NEW_HASH" == "$CURRENT_HASH" ]]; then + echo "Hash is already up to date." +else + if $CHECK_MODE; then + echo "ERROR: npmDepsHash is stale." + echo " current: $CURRENT_HASH" + echo " correct: $NEW_HASH" + echo "Run ./scripts/update-nix.sh to fix." + exit 1 + fi + + echo "Updating npmDepsHash in nix/package.nix..." + sed -i.bak "s|npmDepsHash = \".*\"|npmDepsHash = \"$NEW_HASH\"|" "$PACKAGE_NIX" + rm -f "$PACKAGE_NIX.bak" + echo "Updated: $CURRENT_HASH -> $NEW_HASH" +fi