diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index a4a248e1cf..f37a8b0cdf 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -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 "" echo "::endgroup::" - echo "::group::paseo-server stdout/stderr" + echo "::group::paseo daemon run stdout/stderr" cat "$WRAPPER_LOG" 2>/dev/null || echo "" echo "::endgroup::" diff --git a/nix/desktop-package.nix b/nix/desktop-package.nix index d00b382083..dae45db386 100644 --- a/nix/desktop-package.nix +++ b/nix/desktop-package.nix @@ -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 diff --git a/nix/package.nix b/nix/package.nix index 417f3d2ad1..b032d88396 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -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/ diff --git a/packages/desktop/src/integrations/cli-install/path.test.ts b/packages/desktop/src/integrations/cli-install/path.test.ts index 472f066e00..77d340b409 100644 --- a/packages/desktop/src/integrations/cli-install/path.test.ts +++ b/packages/desktop/src/integrations/cli-install/path.test.ts @@ -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")); + }); +}); diff --git a/packages/desktop/src/integrations/cli-install/path.ts b/packages/desktop/src/integrations/cli-install/path.ts index c9114c2301..0cad7fc757 100644 --- a/packages/desktop/src/integrations/cli-install/path.ts +++ b/packages/desktop/src/integrations/cli-install/path.ts @@ -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); +} diff --git a/packages/desktop/src/integrations/cli-install/paths.ts b/packages/desktop/src/integrations/cli-install/paths.ts index 312a587892..1f7bf98f48 100644 --- a/packages/desktop/src/integrations/cli-install/paths.ts +++ b/packages/desktop/src/integrations/cli-install/paths.ts @@ -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"), + }); } diff --git a/scripts/trace-daemon.mjs b/scripts/trace-daemon.mjs index cb80266c13..2b6e26c3ea 100644 --- a/scripts/trace-daemon.mjs +++ b/scripts/trace-daemon.mjs @@ -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//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))); } }