mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
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:
co-authored by
Mohamed Boudra
parent
3e2d77723e
commit
db303be3c2
@@ -36,7 +36,7 @@ jobs:
|
|||||||
PASEO_HOME="$(mktemp -d)"
|
PASEO_HOME="$(mktemp -d)"
|
||||||
export PASEO_LISTEN=127.0.0.1:6767
|
export PASEO_LISTEN=127.0.0.1:6767
|
||||||
|
|
||||||
WRAPPER_LOG="$PASEO_HOME/paseo-server-wrapper.log"
|
WRAPPER_LOG="$PASEO_HOME/paseo-cli.log"
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
if [[ -n "${DAEMON_PID:-}" ]] && kill -0 "$DAEMON_PID" 2>/dev/null; then
|
if [[ -n "${DAEMON_PID:-}" ]] && kill -0 "$DAEMON_PID" 2>/dev/null; then
|
||||||
@@ -47,7 +47,7 @@ jobs:
|
|||||||
}
|
}
|
||||||
trap cleanup EXIT
|
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=$!
|
DAEMON_PID=$!
|
||||||
|
|
||||||
deadline=$((SECONDS + 30))
|
deadline=$((SECONDS + 30))
|
||||||
@@ -57,7 +57,22 @@ jobs:
|
|||||||
echo "$STATUS_JSON"
|
echo "$STATUS_JSON"
|
||||||
curl --fail --silent --show-error http://127.0.0.1:6767/ >"$PASEO_HOME/web-ui.html"
|
curl --fail --silent --show-error http://127.0.0.1:6767/ >"$PASEO_HOME/web-ui.html"
|
||||||
[[ -s "$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
|
fi
|
||||||
|
|
||||||
if ! kill -0 "$DAEMON_PID" 2>/dev/null; then
|
if ! kill -0 "$DAEMON_PID" 2>/dev/null; then
|
||||||
@@ -72,7 +87,7 @@ jobs:
|
|||||||
cat "$PASEO_HOME/daemon.log" 2>/dev/null || echo "<missing>"
|
cat "$PASEO_HOME/daemon.log" 2>/dev/null || echo "<missing>"
|
||||||
echo "::endgroup::"
|
echo "::endgroup::"
|
||||||
|
|
||||||
echo "::group::paseo-server stdout/stderr"
|
echo "::group::paseo daemon run stdout/stderr"
|
||||||
cat "$WRAPPER_LOG" 2>/dev/null || echo "<missing>"
|
cat "$WRAPPER_LOG" 2>/dev/null || echo "<missing>"
|
||||||
echo "::endgroup::"
|
echo "::endgroup::"
|
||||||
|
|
||||||
|
|||||||
@@ -157,6 +157,9 @@ buildNpmPackage {
|
|||||||
cp -a "$path" "$out/share/paseo-desktop/$path"
|
cp -a "$path" "$out/share/paseo-desktop/$path"
|
||||||
done < desktop-files.txt
|
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.
|
# Keep the same unpackaged monorepo layout expected by main.js.
|
||||||
cp package.json $out/share/paseo-desktop/
|
cp package.json $out/share/paseo-desktop/
|
||||||
mkdir -p $out/share/paseo-desktop/packages/app
|
mkdir -p $out/share/paseo-desktop/packages/app
|
||||||
|
|||||||
@@ -120,6 +120,9 @@ buildNpmPackage rec {
|
|||||||
cp -a "$path" "$out/lib/paseo/$path"
|
cp -a "$path" "$out/lib/paseo/$path"
|
||||||
done < daemon-files.txt
|
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
|
# Root package.json lets node resolve the workspace layout when the
|
||||||
# CLI/server bin starts from $out.
|
# CLI/server bin starts from $out.
|
||||||
cp package.json $out/lib/paseo/
|
cp package.json $out/lib/paseo/
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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", () => {
|
describe("cli-install-path", () => {
|
||||||
it("uses the bundled shim for packaged macOS installs", () => {
|
it("uses the bundled shim for packaged macOS installs", () => {
|
||||||
@@ -56,3 +58,38 @@ describe("cli-install-path", () => {
|
|||||||
).toBe("/opt/Paseo/resources/bin/paseo");
|
).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: {
|
export function resolveCliInstallSourcePath(input: {
|
||||||
platform: NodeJS.Platform;
|
platform: NodeJS.Platform;
|
||||||
isPackaged: boolean;
|
isPackaged: boolean;
|
||||||
@@ -27,3 +29,19 @@ export function resolveCliInstallSourcePath(input: {
|
|||||||
|
|
||||||
return input.executablePath;
|
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 path from "node:path";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { resolveCliShimPath } from "./path.js";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import { app } from "electron";
|
import { app } from "electron";
|
||||||
|
|
||||||
@@ -12,20 +14,10 @@ export function getCliTargetPath(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getBundledCliShimPath(): string {
|
export function getBundledCliShimPath(): string {
|
||||||
const cliShimFilename = process.platform === "win32" ? "paseo.cmd" : "paseo";
|
return resolveCliShimPath({
|
||||||
|
platform: process.platform,
|
||||||
if (process.platform === "darwin") {
|
isPackaged: app.isPackaged,
|
||||||
const electronExePath = app.getPath("exe");
|
executablePath: app.getPath("exe"),
|
||||||
const appBundle = electronExePath.replace(/\/Contents\/MacOS\/.+$/, "");
|
resolveWorkspaceCli: () => createRequire(__filename).resolve("@getpaseo/cli/bin/paseo"),
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+67
-18
@@ -13,7 +13,8 @@
|
|||||||
// node_modules populated (the Nix build invokes this post-configHook).
|
// node_modules populated (the Nix build invokes this post-configHook).
|
||||||
|
|
||||||
import { nodeFileTrace } from "@vercel/nft";
|
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 path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
|
|
||||||
@@ -29,6 +30,47 @@ const { sherpaPlatformPackageName } = await import(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const traceDesktop = process.env.PASEO_TRACE_DESKTOP === "1";
|
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
|
// Daemon entry points. Workers forked into their own Node processes have
|
||||||
// independent require trees; nft does not follow fork boundaries, so trace
|
// 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",
|
"packages/server/dist/server/server/agent/providers/opencode/**/bridge-plugin.bundle.mjs",
|
||||||
// Server runtime config files (read by path, not require)
|
// Server runtime config files (read by path, not require)
|
||||||
"packages/server/.env.example",
|
"packages/server/.env.example",
|
||||||
// CLI shebang script wrapping dist/index.js
|
...ptyNativeFiles,
|
||||||
"packages/cli/bin/paseo",
|
// Resolve native speech packages from the same modules as the runtime.
|
||||||
// node-pty's compiled native addon. nft can't trace it because
|
resolvedPackageFiles(sherpaModule, "sherpa-onnx-node"),
|
||||||
// node-pty loads it via `require(path.join(__dirname, 'prebuilds/<plat>/pty.node'))`
|
resolvedPackageFiles(sherpaEnvModule, sherpaPlatformPackageName()),
|
||||||
// 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()}/**`,
|
|
||||||
...(traceDesktop
|
...(traceDesktop
|
||||||
? [
|
? [
|
||||||
// The unpackaged Nix launcher resolves these beside desktop/dist.
|
// The unpackaged Nix launcher resolves these beside desktop/dist.
|
||||||
"packages/desktop/package.json",
|
"packages/desktop/package.json",
|
||||||
"packages/desktop/assets/**",
|
"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.
|
// Trace.
|
||||||
const { fileList, warnings } = await nodeFileTrace(entries, {
|
const { fileList, warnings } = await nodeFileTrace(entries, {
|
||||||
base: REPO_ROOT,
|
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:
|
// Tolerate the conditional / dynamic patterns we already audited:
|
||||||
// sherpa-onnx-${platform}-${arch} package resolution (the host package
|
// sherpa-onnx-${platform}-${arch} package resolution (the host package
|
||||||
// is copied explicitly above), and a handful of test-only requires that
|
// 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) {
|
for (const pattern of additionalInputs) {
|
||||||
if (pattern.includes("*")) {
|
if (pattern.includes("*")) {
|
||||||
for await (const file of glob(pattern, { cwd: REPO_ROOT })) {
|
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 {
|
} else {
|
||||||
expanded.add(pattern);
|
expanded.add(path.relative(REPO_ROOT, path.resolve(REPO_ROOT, pattern)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user