fix(nix): ship resolved daemon dependencies and working terminals (#5523)

* fix(nix): ship @getpaseo/server's main export in the desktop closure

The desktop's runtime-paths.js locates the server package root with
require.resolve("@getpaseo/server"), which resolves through the exports
map to dist/server/server/exports.js. The desktop never loads that file,
so nft doesn't trace it, and the Linux Nix desktop build fails to start
the daemon with MODULE_NOT_FOUND.

* fix(nix): retain the server resolution anchor for CLI and desktop

Run the existing Nix daemon smoke through the built CLI so package-root resolution is exercised before readiness and HTTP checks.

Co-authored-by: Callum Wong <mail@callumwong.com>

* fix(nix): ship complete terminal runtimes

Resolve runtime dependency edges at their importing modules so package links and exports survive closure tracing regardless of npm hoisting. Use the native loader's selected files and verify terminal commands and hook CLI execution through the built package.

Co-authored-by: Callum Wong <mail@callumwong.com>

* test(desktop): make packaged CLI path expectations portable

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
Callum Wong
2026-10-05 19:25:48 +01:00
committed by GitHub
co-authored by Mohamed Boudra
parent 3e2d77723e
commit db303be3c2
7 changed files with 156 additions and 39 deletions
+19 -4
View File
@@ -36,7 +36,7 @@ jobs:
PASEO_HOME="$(mktemp -d)"
export PASEO_LISTEN=127.0.0.1:6767
WRAPPER_LOG="$PASEO_HOME/paseo-server-wrapper.log"
WRAPPER_LOG="$PASEO_HOME/paseo-cli.log"
cleanup() {
if [[ -n "${DAEMON_PID:-}" ]] && kill -0 "$DAEMON_PID" 2>/dev/null; then
@@ -47,7 +47,7 @@ jobs:
}
trap cleanup EXIT
PASEO_WEB_UI_ENABLED=true ./result/bin/paseo-server --no-relay >"$WRAPPER_LOG" 2>&1 &
PASEO_WEB_UI_ENABLED=true PASEO_RELAY_ENABLED=false ./result/bin/paseo daemon run >"$WRAPPER_LOG" 2>&1 &
DAEMON_PID=$!
deadline=$((SECONDS + 30))
@@ -57,7 +57,22 @@ jobs:
echo "$STATUS_JSON"
curl --fail --silent --show-error http://127.0.0.1:6767/ >"$PASEO_HOME/web-ui.html"
[[ -s "$PASEO_HOME/web-ui.html" ]]
exit 0
if ! TERMINAL_JSON="$(./result/bin/paseo terminal create --cwd "$PASEO_HOME" --name nix-smoke --json)"; then
break
fi
TERMINAL_ID="$(jq -er '.id' <<<"$TERMINAL_JSON")"
./result/bin/paseo terminal send-keys "$TERMINAL_ID" 'PATH= "$PASEO_HOOK_CLI" --version && printf "nix-terminal-%s\n" ok' Enter
deadline=$((SECONDS + 15))
while (( SECONDS < deadline )); do
if ./result/bin/paseo terminal capture "$TERMINAL_ID" --json \
| jq -e '.lines | any(. == "nix-terminal-ok")' >/dev/null; then
./result/bin/paseo terminal kill "$TERMINAL_ID"
exit 0
fi
sleep 1
done
echo "Nix terminal did not execute the command."
break
fi
if ! kill -0 "$DAEMON_PID" 2>/dev/null; then
@@ -72,7 +87,7 @@ jobs:
cat "$PASEO_HOME/daemon.log" 2>/dev/null || echo "<missing>"
echo "::endgroup::"
echo "::group::paseo-server stdout/stderr"
echo "::group::paseo daemon run stdout/stderr"
cat "$WRAPPER_LOG" 2>/dev/null || echo "<missing>"
echo "::endgroup::"
+3
View File
@@ -157,6 +157,9 @@ buildNpmPackage {
cp -a "$path" "$out/share/paseo-desktop/$path"
done < desktop-files.txt
# Shell hooks invoke the retained CLI bin directly, without a system Node.
patchShebangs --build "$out/share/paseo-desktop"
# Keep the same unpackaged monorepo layout expected by main.js.
cp package.json $out/share/paseo-desktop/
mkdir -p $out/share/paseo-desktop/packages/app
+3
View File
@@ -120,6 +120,9 @@ buildNpmPackage rec {
cp -a "$path" "$out/lib/paseo/$path"
done < daemon-files.txt
# Shell hooks invoke the retained CLI bin directly, without a system Node.
patchShebangs --build "$out/lib/paseo"
# Root package.json lets node resolve the workspace layout when the
# CLI/server bin starts from $out.
cp package.json $out/lib/paseo/
@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
import { resolveCliInstallSourcePath } from "./path";
import { createRequire } from "node:module";
import path from "node:path";
import { resolveCliInstallSourcePath, resolveCliShimPath } from "./path";
describe("cli-install-path", () => {
it("uses the bundled shim for packaged macOS installs", () => {
@@ -56,3 +58,38 @@ describe("cli-install-path", () => {
).toBe("/opt/Paseo/resources/bin/paseo");
});
});
describe("CLI executable selection", () => {
const resolveWorkspaceCli = () =>
createRequire(import.meta.url).resolve("@getpaseo/cli/bin/paseo");
it("uses the workspace CLI for an unpackaged Electron launcher", () => {
expect(
resolveCliShimPath({
platform: "linux",
isPackaged: false,
executablePath: "/nix/store/electron/bin/electron",
resolveWorkspaceCli,
}),
).toBe(resolveWorkspaceCli());
});
it("uses the application shim for a packaged launcher", () => {
expect(
resolveCliShimPath({
platform: "linux",
isPackaged: true,
executablePath: "/opt/Paseo/paseo",
resolveWorkspaceCli,
}),
).toBe(path.join("/opt/Paseo", "resources", "bin", "paseo"));
expect(
resolveCliShimPath({
platform: "darwin",
isPackaged: true,
executablePath: "/Applications/Paseo.app/Contents/MacOS/Paseo",
resolveWorkspaceCli,
}),
).toBe(path.join("/Applications/Paseo.app", "Contents", "Resources", "bin", "paseo"));
});
});
@@ -1,3 +1,5 @@
import path from "node:path";
export function resolveCliInstallSourcePath(input: {
platform: NodeJS.Platform;
isPackaged: boolean;
@@ -27,3 +29,19 @@ export function resolveCliInstallSourcePath(input: {
return input.executablePath;
}
export function resolveCliShimPath(input: {
platform: NodeJS.Platform;
isPackaged: boolean;
executablePath: string;
resolveWorkspaceCli: () => string;
}): string {
if (!input.isPackaged) return input.resolveWorkspaceCli();
const filename = input.platform === "win32" ? "paseo.cmd" : "paseo";
if (input.platform === "darwin") {
const bundle = input.executablePath.replace(/\/Contents\/MacOS\/.+$/, "");
return path.join(bundle, "Contents", "Resources", "bin", filename);
}
return path.join(path.dirname(input.executablePath), "resources", "bin", filename);
}
@@ -1,4 +1,6 @@
import path from "node:path";
import { createRequire } from "node:module";
import { resolveCliShimPath } from "./path.js";
import os from "node:os";
import { app } from "electron";
@@ -12,20 +14,10 @@ export function getCliTargetPath(): string {
}
export function getBundledCliShimPath(): string {
const cliShimFilename = process.platform === "win32" ? "paseo.cmd" : "paseo";
if (process.platform === "darwin") {
const electronExePath = app.getPath("exe");
const appBundle = electronExePath.replace(/\/Contents\/MacOS\/.+$/, "");
return path.join(appBundle, "Contents", "Resources", "bin", cliShimFilename);
}
if (process.platform === "win32") {
const electronExePath = app.getPath("exe");
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
}
// Linux
const electronExePath = app.getPath("exe");
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
return resolveCliShimPath({
platform: process.platform,
isPackaged: app.isPackaged,
executablePath: app.getPath("exe"),
resolveWorkspaceCli: () => createRequire(__filename).resolve("@getpaseo/cli/bin/paseo"),
});
}
+67 -18
View File
@@ -13,7 +13,8 @@
// node_modules populated (the Nix build invokes this post-configHook).
import { nodeFileTrace } from "@vercel/nft";
import { glob } from "node:fs/promises";
import { glob, readFile, readdir } from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path";
import { pathToFileURL } from "node:url";
@@ -29,6 +30,47 @@ const { sherpaPlatformPackageName } = await import(
);
const traceDesktop = process.env.PASEO_TRACE_DESKTOP === "1";
const terminalModule = "packages/server/dist/server/terminal/terminal.js";
const sherpaModule =
"packages/server/dist/server/server/speech/providers/local/sherpa/sherpa-onnx-node-loader.js";
const sherpaEnvModule =
"packages/server/dist/server/server/speech/providers/local/sherpa/sherpa-runtime-env.js";
// Resolve-only and computed requires need explicit graph edges, not installed
// paths. Analyze them at their real importer so nft retains the package exports,
// manifests and workspace symlinks used by Node's resolution.
const runtimeDependencies = new Map([
["packages/cli/dist/commands/daemon/local-daemon.js", ["@getpaseo/server"]],
[terminalModule, ["@getpaseo/cli/bin/paseo", "node-pty/package.json"]],
[sherpaModule, ["sherpa-onnx-node"]],
[sherpaEnvModule, [`${sherpaPlatformPackageName()}/package.json`]],
...(traceDesktop
? [
["packages/desktop/dist/daemon/runtime-paths.js", ["@getpaseo/server"]],
["packages/desktop/dist/integrations/cli-install/paths.js", ["@getpaseo/cli/bin/paseo"]],
]
: []),
]);
function requireFrom(importer) {
return createRequire(path.join(REPO_ROOT, importer));
}
function resolvedPackageFiles(importer, specifier) {
const manifest = requireFrom(importer).resolve(`${specifier}/package.json`);
return path.join(path.dirname(manifest), "**");
}
// Let node-pty select the native build it actually loads. npm hoisting and
// prebuild/build layout are owned by the package, not this trace. Its Darwin
// spawn-helper lives beside the selected addon; retain the files in that dir.
const terminalRequire = requireFrom(terminalModule);
const ptyLoader = terminalRequire.resolve("node-pty/lib/utils");
const { dir: ptyNativeDir } = terminalRequire(ptyLoader).loadNativeModule("pty");
const ptyNativeRoot = path.resolve(path.dirname(ptyLoader), ptyNativeDir);
const ptyNativeFiles = (await readdir(ptyNativeRoot, { withFileTypes: true }))
.filter((entry) => entry.isFile())
.map((entry) => path.join(ptyNativeRoot, entry.name));
// Daemon entry points. Workers forked into their own Node processes have
// independent require trees; nft does not follow fork boundaries, so trace
@@ -63,33 +105,40 @@ const additionalInputs = [
"packages/server/dist/server/server/agent/providers/opencode/**/bridge-plugin.bundle.mjs",
// Server runtime config files (read by path, not require)
"packages/server/.env.example",
// CLI shebang script wrapping dist/index.js
"packages/cli/bin/paseo",
// node-pty's compiled native addon. nft can't trace it because
// node-pty loads it via `require(path.join(__dirname, 'prebuilds/<plat>/pty.node'))`
// with a runtime-computed platform suffix. Pin to the host platform —
// the Nix derivation builds for one platform at a time and ships only
// its own binaries.
`node_modules/node-pty/prebuilds/${process.platform}-${process.arch}/**`,
// sherpa-onnx-node dynamically resolves a platform-specific native package.
// Copy the wrapper plus the host platform package explicitly.
"node_modules/sherpa-onnx-node/**",
`node_modules/${sherpaPlatformPackageName()}/**`,
...ptyNativeFiles,
// Resolve native speech packages from the same modules as the runtime.
resolvedPackageFiles(sherpaModule, "sherpa-onnx-node"),
resolvedPackageFiles(sherpaEnvModule, sherpaPlatformPackageName()),
...(traceDesktop
? [
// The unpackaged Nix launcher resolves these beside desktop/dist.
"packages/desktop/package.json",
"packages/desktop/assets/**",
// resolveExternalCliEntrypoint() looks up the workspace through this
// link at runtime; nft traces the target files but not the link.
"node_modules/@getpaseo/cli",
]
: []),
];
// Fail at build time if a required runtime edge cannot resolve.
for (const [importer, specifiers] of runtimeDependencies) {
for (const specifier of specifiers) requireFrom(importer).resolve(specifier);
}
// Trace.
const { fileList, warnings } = await nodeFileTrace(entries, {
base: REPO_ROOT,
async readFile(file) {
let source;
try {
source = await readFile(file);
} catch (error) {
if (error.code === "ENOENT" || error.code === "EISDIR") return null;
throw error;
}
const dependencies = runtimeDependencies.get(path.relative(REPO_ROOT, file));
if (!dependencies) return source;
// These statements exist only in nft's input, never in the shipped code.
return `${source}\n${dependencies.map((specifier) => `require(${JSON.stringify(specifier)});`).join("\n")}`;
},
// Tolerate the conditional / dynamic patterns we already audited:
// sherpa-onnx-${platform}-${arch} package resolution (the host package
// is copied explicitly above), and a handful of test-only requires that
@@ -129,10 +178,10 @@ const expanded = new Set(fileList);
for (const pattern of additionalInputs) {
if (pattern.includes("*")) {
for await (const file of glob(pattern, { cwd: REPO_ROOT })) {
expanded.add(file);
expanded.add(path.relative(REPO_ROOT, path.resolve(REPO_ROOT, file)));
}
} else {
expanded.add(pattern);
expanded.add(path.relative(REPO_ROOT, path.resolve(REPO_ROOT, pattern)));
}
}