From 2a5ac7a0135eb7489dee4ae770816c896be8caa2 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 9 Apr 2026 00:27:20 +0700 Subject: [PATCH] Commit current desktop and CLI diff --- .../components/desktop-updates-section.tsx | 213 +----------------- packages/app/src/screens/settings-screen.tsx | 8 +- packages/cli/src/commands/daemon/status.ts | 34 +-- .../tests/e2e/opencode-invalid-model.test.ts | 72 ++++++ packages/desktop/src/daemon/daemon-manager.ts | 18 +- packages/desktop/src/daemon/runtime-paths.ts | 10 +- .../src/integrations/integrations-manager.ts | 15 +- 7 files changed, 126 insertions(+), 244 deletions(-) create mode 100644 packages/cli/tests/e2e/opencode-invalid-model.test.ts diff --git a/packages/app/src/desktop/components/desktop-updates-section.tsx b/packages/app/src/desktop/components/desktop-updates-section.tsx index df0756eff..ef8be49ab 100644 --- a/packages/app/src/desktop/components/desktop-updates-section.tsx +++ b/packages/app/src/desktop/components/desktop-updates-section.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useState } from "react"; -import { ActivityIndicator, Alert, Image, Text, View } from "react-native"; +import { ActivityIndicator, Alert, Text, View } from "react-native"; import * as Clipboard from "expo-clipboard"; -import * as QRCode from "qrcode"; import { useFocusEffect } from "@react-navigation/native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { settingsStyles } from "@/styles/settings"; @@ -12,7 +11,6 @@ import { RotateCw, Copy, FileText, - Smartphone, Activity, } from "lucide-react-native"; import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; @@ -24,7 +22,6 @@ import { isVersionMismatch } from "@/desktop/updates/desktop-updates"; import { getCliDaemonStatus, getDesktopDaemonLogs, - getDesktopDaemonPairing, getDesktopDaemonStatus, restartDesktopDaemon, shouldUseDesktopDaemon, @@ -32,7 +29,6 @@ import { stopDesktopDaemon, type DesktopDaemonLogs, type DesktopDaemonStatus, - type DesktopPairingOffer, } from "@/desktop/daemon/desktop-daemon"; export interface LocalDaemonSectionProps { @@ -52,10 +48,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD const [statusMessage, setStatusMessage] = useState(null); const [daemonLogs, setDaemonLogs] = useState(null); const [isLogsModalOpen, setIsLogsModalOpen] = useState(false); - const [isPairingModalOpen, setIsPairingModalOpen] = useState(false); - const [isLoadingPairing, setIsLoadingPairing] = useState(false); - const [pairingOffer, setPairingOffer] = useState(null); - const [pairingStatusMessage, setPairingStatusMessage] = useState(null); const [cliStatusOutput, setCliStatusOutput] = useState(null); const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false); const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false); @@ -238,46 +230,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD setIsLogsModalOpen(true); }, [daemonLogs]); - const handleOpenPairingModal = useCallback(() => { - if (isLoadingPairing) { - return; - } - - setIsPairingModalOpen(true); - setIsLoadingPairing(true); - setPairingStatusMessage(null); - - void getDesktopDaemonPairing() - .then((pairing) => { - setPairingOffer(pairing); - if (!pairing.relayEnabled || !pairing.url) { - setPairingStatusMessage("Relay pairing is not available."); - } - }) - .catch((error) => { - const message = error instanceof Error ? error.message : String(error); - setPairingOffer(null); - setPairingStatusMessage(`Unable to load pairing offer: ${message}`); - }) - .finally(() => { - setIsLoadingPairing(false); - }); - }, [isLoadingPairing]); - - const handleCopyPairingLink = useCallback(() => { - if (!pairingOffer?.url) { - return; - } - void Clipboard.setStringAsync(pairingOffer.url) - .then(() => { - Alert.alert("Copied", "Pairing link copied."); - }) - .catch((error) => { - console.error("[Settings] Failed to copy pairing link", error); - Alert.alert("Error", "Unable to copy pairing link."); - }); - }, [pairingOffer?.url]); - const handleOpenCliStatus = useCallback(async () => { setIsLoadingCliStatus(true); try { @@ -418,20 +370,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD - - - Pair device - Connect your phone to this computer. - - - Full status @@ -460,20 +398,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD ) : null} - setIsPairingModalOpen(false)} - title="Pair device" - testID="managed-daemon-pairing-dialog" - > - - - setIsLogsModalOpen(false)} @@ -516,107 +440,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD const ADVANCED_DAEMON_SETTINGS_URL = "https://paseo.sh/docs/configuration"; -function PairingOfferDialogContent(input: { - isLoading: boolean; - pairingOffer: DesktopPairingOffer | null; - statusMessage: string | null; - onCopyLink: () => void; -}) { - const { isLoading, pairingOffer, statusMessage, onCopyLink } = input; - const [qrDataUrl, setQrDataUrl] = useState(null); - const [qrError, setQrError] = useState(null); - - useEffect(() => { - let cancelled = false; - - if (!pairingOffer?.url) { - setQrDataUrl(null); - setQrError(null); - return () => { - cancelled = true; - }; - } - - setQrError(null); - setQrDataUrl(null); - - void QRCode.toDataURL(pairingOffer.url, { - errorCorrectionLevel: "M", - margin: 1, - width: 480, - }) - .then((dataUrl) => { - if (cancelled) { - return; - } - setQrDataUrl(dataUrl); - }) - .catch((error) => { - if (cancelled) { - return; - } - setQrError(error instanceof Error ? error.message : String(error)); - }); - - return () => { - cancelled = true; - }; - }, [pairingOffer?.url]); - - if (isLoading) { - return ( - - - Loading pairing offer… - - ); - } - - if (statusMessage) { - return ( - - {statusMessage} - - ); - } - - if (!pairingOffer?.url) { - return ( - - Pairing offer unavailable. - - ); - } - - return ( - - - Scan this QR code in Paseo, or copy the pairing link below. - - - {qrDataUrl ? ( - - ) : qrError ? ( - QR unavailable: {qrError} - ) : ( - - )} - - - Pairing link - - {pairingOffer.url} - - - - - - - ); -} - const styles = StyleSheet.create((theme) => ({ actionGroup: { flexDirection: "row", @@ -659,40 +482,6 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[3], paddingBottom: theme.spacing[2], }, - pairingState: { - alignItems: "center", - justifyContent: "center", - gap: theme.spacing[3], - paddingVertical: theme.spacing[6], - }, - qrCard: { - alignItems: "center", - justifyContent: "center", - width: "100%", - aspectRatio: 1, - alignSelf: "stretch", - padding: theme.spacing[3], - borderRadius: theme.borderRadius.lg, - borderWidth: 1, - borderColor: theme.colors.border, - backgroundColor: theme.colors.surface0, - }, - qrImage: { - width: "100%", - height: "100%", - }, - linkSection: { - gap: theme.spacing[2], - }, - linkLabel: { - color: theme.colors.foreground, - fontSize: theme.fontSize.sm, - }, - linkText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - lineHeight: 18, - }, logOutput: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 465402c1f..61228b477 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -22,6 +22,7 @@ import { Shield, Puzzle, Blocks, + Smartphone, } from "lucide-react-native"; import { useAppSettings, type AppSettings } from "@/hooks/use-settings"; import type { HostProfile, HostConnection } from "@/types/host-connection"; @@ -54,6 +55,7 @@ import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-mod import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section"; import { IntegrationsSection } from "@/desktop/components/integrations-section"; import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section"; +import { PairDeviceSection } from "@/desktop/components/pair-device-section"; import { isElectronRuntime } from "@/desktop/host"; import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater"; import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates"; @@ -82,7 +84,8 @@ type SettingsSectionId = | "diagnostics" | "about" | "permissions" - | "daemon"; + | "daemon" + | "pair-device"; interface SettingsSectionDef { id: SettingsSectionId; @@ -101,6 +104,7 @@ function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectio if (context.isDesktopApp) { sections.push( { id: "integrations", label: "Integrations", icon: Puzzle }, + { id: "pair-device", label: "Pair device", icon: Smartphone }, { id: "daemon", label: "Daemon", icon: Settings }, ); } @@ -646,6 +650,8 @@ function SettingsSectionContent({ return isDesktopApp ? : null; case "permissions": return isDesktopApp ? : null; + case "pair-device": + return isDesktopApp ? : null; case "daemon": return isDesktopApp ? ( diff --git a/packages/cli/src/commands/daemon/status.ts b/packages/cli/src/commands/daemon/status.ts index 1029b53d3..aa3ec6cc1 100644 --- a/packages/cli/src/commands/daemon/status.ts +++ b/packages/cli/src/commands/daemon/status.ts @@ -1,12 +1,14 @@ import type { Command } from "commander"; -import { execFileSync } from "node:child_process"; +import { execFile } from "node:child_process"; import { createRequire } from "node:module"; +import { promisify } from "node:util"; import { getOrCreateServerId, findExecutable, - quoteWindowsCommand, applyProviderEnv, } from "@getpaseo/server"; + +const execFileAsync = promisify(execFile); import { tryConnectToDaemon } from "../../utils/client.js"; import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js"; import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js"; @@ -171,31 +173,33 @@ const PROVIDER_BINARIES: { label: string; binary: string }[] = [ { label: "OpenCode", binary: "opencode" }, ]; -function checkProviderBinary(binary: string): { path: string | null; version: string | null } { - const binaryPath = findExecutable(binary); +async function checkProviderBinary(binary: string): Promise<{ path: string | null; version: string | null }> { + const binaryPath = await findExecutable(binary); if (!binaryPath) { return { path: null, version: null }; } const env = applyProviderEnv(process.env); try { - const output = execFileSync(quoteWindowsCommand(binaryPath), ["--version"], { + const { stdout } = await execFileAsync(binaryPath, ["--version"], { encoding: "utf8", timeout: 5000, - stdio: ["ignore", "pipe", "pipe"], env, - shell: process.platform === "win32", - }).trim(); - return { path: binaryPath, version: output || null }; + windowsHide: true, + }); + return { path: binaryPath, version: stdout.trim() || null }; } catch { return { path: binaryPath, version: null }; } } -function checkProviderBinaries(): ProviderBinaryStatus[] { - return PROVIDER_BINARIES.map(({ label, binary }) => { - const result = checkProviderBinary(binary); - return { label, ...result }; - }); +async function checkProviderBinaries(): Promise { + const results = await Promise.all( + PROVIDER_BINARIES.map(async ({ label, binary }) => { + const result = await checkProviderBinary(binary); + return { label, ...result }; + }), + ); + return results; } function resolveOwnerLabel(uid: number | undefined, hostname: string | undefined): string | null { @@ -295,7 +299,7 @@ export async function runStatusCommand( note = appendNote(note, `serverId unavailable: ${shortenMessage(normalizeError(error))}`); } - const providers = checkProviderBinaries(); + const providers = await checkProviderBinaries(); const daemonStatus: DaemonStatus = { serverId, diff --git a/packages/cli/tests/e2e/opencode-invalid-model.test.ts b/packages/cli/tests/e2e/opencode-invalid-model.test.ts new file mode 100644 index 000000000..ef047ac15 --- /dev/null +++ b/packages/cli/tests/e2e/opencode-invalid-model.test.ts @@ -0,0 +1,72 @@ +#!/usr/bin/env npx tsx + +import assert from "node:assert"; +import { createE2ETestContext, type TestDaemonContext } from "../helpers/test-daemon.ts"; + +interface E2EContext extends TestDaemonContext { + paseo: ( + args: string[], + opts?: { timeout?: number; cwd?: string }, + ) => Promise<{ + exitCode: number; + stdout: string; + stderr: string; + }>; +} + +let ctx: E2EContext; + +async function setup(): Promise { + ctx = await createE2ETestContext({ timeout: 45_000 }); +} + +async function cleanup(): Promise { + if (ctx) { + await ctx.stop(); + } +} + +async function test_invalid_opencode_model_does_not_report_completed_while_still_running() { + const result = await ctx.paseo( + ["run", "--provider", "opencode/adklasldkdas", "hello"], + { timeout: 45_000 }, + ); + + const output = `${result.stdout}\n${result.stderr}`; + const agentId = output.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i)?.[0]; + + if (result.exitCode !== 0) { + assert( + output.toLowerCase().includes("error") || output.toLowerCase().includes("failed"), + `expected invalid model failure output\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); + return; + } + + assert(agentId, `expected run output to include an agent id\nstdout:\n${result.stdout}`); + + const inspect = await ctx.paseo(["inspect", agentId], { timeout: 15_000 }); + assert.strictEqual(inspect.exitCode, 0, `inspect failed\nstdout:\n${inspect.stdout}\nstderr:\n${inspect.stderr}`); + + const runReportedCompleted = result.stdout.includes("completed"); + const inspectStillRunning = inspect.stdout.includes("Status running"); + + assert( + !(runReportedCompleted && inspectStillRunning), + `run reported completed while inspect still showed running\nrun stdout:\n${result.stdout}\ninspect stdout:\n${inspect.stdout}`, + ); +} + +async function main(): Promise { + try { + await setup(); + await test_invalid_opencode_model_does_not_report_completed_while_still_running(); + } catch (error) { + console.error(error); + process.exitCode = 1; + } finally { + await cleanup(); + } +} + +main(); diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index a29cd8ae3..af9942456 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -1,9 +1,9 @@ -import { spawn, type ChildProcess } from "node:child_process"; +import { type ChildProcess } from "node:child_process"; import { readFileSync } from "node:fs"; import path from "node:path"; import { app, ipcMain } from "electron"; import log from "electron-log/main"; -import { resolvePaseoHome } from "@getpaseo/server"; +import { resolvePaseoHome, spawnProcess } from "@getpaseo/server"; import { copyAttachmentFileToManagedStorage, deleteManagedAttachmentFile, @@ -266,15 +266,11 @@ async function startDaemon(): Promise { args: invocation.args, }); - const child: ChildProcess = spawn( - invocation.command, - invocation.args, - { - detached: true, - env: { ...invocation.env, PASEO_DESKTOP_MANAGED: "1" }, - stdio: ["ignore", "ignore", "ignore"], - }, - ); + const child: ChildProcess = spawnProcess(invocation.command, invocation.args, { + detached: true, + env: { ...invocation.env, PASEO_DESKTOP_MANAGED: "1" }, + stdio: ["ignore", "ignore", "ignore"], + }); logDesktopDaemonLifecycle("detached spawn returned", { childPid: child.pid ?? null, diff --git a/packages/desktop/src/daemon/runtime-paths.ts b/packages/desktop/src/daemon/runtime-paths.ts index 88615f942..4245ce1b9 100644 --- a/packages/desktop/src/daemon/runtime-paths.ts +++ b/packages/desktop/src/daemon/runtime-paths.ts @@ -1,5 +1,6 @@ import { existsSync, readFileSync } from "node:fs"; -import { spawn, spawnSync } from "node:child_process"; +import { spawnSync } from "node:child_process"; +import { spawnProcess } from "@getpaseo/server"; import { createRequire } from "node:module"; import path from "node:path"; import { app } from "electron"; @@ -234,6 +235,7 @@ export function runCliPassthroughCommand(args: string[]): number { const result = spawnSync(invocation.command, invocation.args, { env: invocation.env, stdio: "inherit", + windowsHide: true, }); if (result.error) { throw result.error; @@ -252,7 +254,7 @@ function spawnAsync( options: { env: NodeJS.ProcessEnv }, ): Promise<{ stdout: string; stderr: string; exitCode: number | null }> { return new Promise((resolve, reject) => { - const child = spawn(command, args, { + const child = spawnProcess(command, args, { env: options.env, stdio: ["ignore", "pipe", "pipe"], }); @@ -260,10 +262,10 @@ function spawnAsync( let stdout = ""; let stderr = ""; - child.stdout.on("data", (data: Buffer) => { + child.stdout!.on("data", (data: Buffer) => { stdout += data.toString(); }); - child.stderr.on("data", (data: Buffer) => { + child.stderr!.on("data", (data: Buffer) => { stderr += data.toString(); }); diff --git a/packages/desktop/src/integrations/integrations-manager.ts b/packages/desktop/src/integrations/integrations-manager.ts index b70894b71..17746a70a 100644 --- a/packages/desktop/src/integrations/integrations-manager.ts +++ b/packages/desktop/src/integrations/integrations-manager.ts @@ -200,7 +200,20 @@ export async function installCli(): Promise { if (await pathOrSymlinkExists(targetPath)) { await fs.unlink(targetPath); } - await fs.copyFile(installSourcePath, targetPath); + // Generate a thin .cmd trampoline that delegates to the bundled shim. + // Only the app install path is baked in — internal details (asar layout, + // entrypoint scripts) live in the bundled shim and update with the app. + const cmdContent = [ + "@echo off", + `set "BUNDLED_CLI=${shimPath}"`, + `if not exist "%BUNDLED_CLI%" (`, + ` echo Paseo CLI not found at %BUNDLED_CLI% — is Paseo installed? 1>&2`, + ` exit /b 1`, + `)`, + `call "%BUNDLED_CLI%" %*`, + `exit /b %errorlevel%`, + ].join("\r\n"); + await fs.writeFile(targetPath, cmdContent, "utf-8"); } else { if (await pathOrSymlinkExists(targetPath)) { await fs.unlink(targetPath);