Commit current desktop and CLI diff
This commit is contained in:
@@ -1,7 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
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 Clipboard from "expo-clipboard";
|
||||||
import * as QRCode from "qrcode";
|
|
||||||
import { useFocusEffect } from "@react-navigation/native";
|
import { useFocusEffect } from "@react-navigation/native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { settingsStyles } from "@/styles/settings";
|
import { settingsStyles } from "@/styles/settings";
|
||||||
@@ -12,7 +11,6 @@ import {
|
|||||||
RotateCw,
|
RotateCw,
|
||||||
Copy,
|
Copy,
|
||||||
FileText,
|
FileText,
|
||||||
Smartphone,
|
|
||||||
Activity,
|
Activity,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||||
@@ -24,7 +22,6 @@ import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
|
|||||||
import {
|
import {
|
||||||
getCliDaemonStatus,
|
getCliDaemonStatus,
|
||||||
getDesktopDaemonLogs,
|
getDesktopDaemonLogs,
|
||||||
getDesktopDaemonPairing,
|
|
||||||
getDesktopDaemonStatus,
|
getDesktopDaemonStatus,
|
||||||
restartDesktopDaemon,
|
restartDesktopDaemon,
|
||||||
shouldUseDesktopDaemon,
|
shouldUseDesktopDaemon,
|
||||||
@@ -32,7 +29,6 @@ import {
|
|||||||
stopDesktopDaemon,
|
stopDesktopDaemon,
|
||||||
type DesktopDaemonLogs,
|
type DesktopDaemonLogs,
|
||||||
type DesktopDaemonStatus,
|
type DesktopDaemonStatus,
|
||||||
type DesktopPairingOffer,
|
|
||||||
} from "@/desktop/daemon/desktop-daemon";
|
} from "@/desktop/daemon/desktop-daemon";
|
||||||
|
|
||||||
export interface LocalDaemonSectionProps {
|
export interface LocalDaemonSectionProps {
|
||||||
@@ -52,10 +48,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
|||||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||||
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
|
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
|
||||||
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
|
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
|
||||||
const [isPairingModalOpen, setIsPairingModalOpen] = useState(false);
|
|
||||||
const [isLoadingPairing, setIsLoadingPairing] = useState(false);
|
|
||||||
const [pairingOffer, setPairingOffer] = useState<DesktopPairingOffer | null>(null);
|
|
||||||
const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null);
|
|
||||||
const [cliStatusOutput, setCliStatusOutput] = useState<string | null>(null);
|
const [cliStatusOutput, setCliStatusOutput] = useState<string | null>(null);
|
||||||
const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false);
|
const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false);
|
||||||
const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false);
|
const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false);
|
||||||
@@ -238,46 +230,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
|||||||
setIsLogsModalOpen(true);
|
setIsLogsModalOpen(true);
|
||||||
}, [daemonLogs]);
|
}, [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 () => {
|
const handleOpenCliStatus = useCallback(async () => {
|
||||||
setIsLoadingCliStatus(true);
|
setIsLoadingCliStatus(true);
|
||||||
try {
|
try {
|
||||||
@@ -418,20 +370,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
|||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
|
||||||
<View style={settingsStyles.rowContent}>
|
|
||||||
<Text style={settingsStyles.rowTitle}>Pair device</Text>
|
|
||||||
<Text style={settingsStyles.rowHint}>Connect your phone to this computer.</Text>
|
|
||||||
</View>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
leftIcon={<Smartphone size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
|
||||||
onPress={handleOpenPairingModal}
|
|
||||||
>
|
|
||||||
Pair device
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||||
<View style={settingsStyles.rowContent}>
|
<View style={settingsStyles.rowContent}>
|
||||||
<Text style={settingsStyles.rowTitle}>Full status</Text>
|
<Text style={settingsStyles.rowTitle}>Full status</Text>
|
||||||
@@ -460,20 +398,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<AdaptiveModalSheet
|
|
||||||
visible={isPairingModalOpen}
|
|
||||||
onClose={() => setIsPairingModalOpen(false)}
|
|
||||||
title="Pair device"
|
|
||||||
testID="managed-daemon-pairing-dialog"
|
|
||||||
>
|
|
||||||
<PairingOfferDialogContent
|
|
||||||
isLoading={isLoadingPairing}
|
|
||||||
pairingOffer={pairingOffer}
|
|
||||||
statusMessage={pairingStatusMessage}
|
|
||||||
onCopyLink={handleCopyPairingLink}
|
|
||||||
/>
|
|
||||||
</AdaptiveModalSheet>
|
|
||||||
|
|
||||||
<AdaptiveModalSheet
|
<AdaptiveModalSheet
|
||||||
visible={isLogsModalOpen}
|
visible={isLogsModalOpen}
|
||||||
onClose={() => setIsLogsModalOpen(false)}
|
onClose={() => setIsLogsModalOpen(false)}
|
||||||
@@ -516,107 +440,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
|||||||
|
|
||||||
const ADVANCED_DAEMON_SETTINGS_URL = "https://paseo.sh/docs/configuration";
|
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<string | null>(null);
|
|
||||||
const [qrError, setQrError] = useState<string | null>(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 (
|
|
||||||
<View style={styles.pairingState}>
|
|
||||||
<ActivityIndicator size="small" />
|
|
||||||
<Text style={settingsStyles.rowHint}>Loading pairing offer…</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (statusMessage) {
|
|
||||||
return (
|
|
||||||
<View style={styles.modalBody}>
|
|
||||||
<Text style={settingsStyles.rowHint}>{statusMessage}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!pairingOffer?.url) {
|
|
||||||
return (
|
|
||||||
<View style={styles.modalBody}>
|
|
||||||
<Text style={settingsStyles.rowHint}>Pairing offer unavailable.</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.modalBody}>
|
|
||||||
<Text style={settingsStyles.rowHint}>
|
|
||||||
Scan this QR code in Paseo, or copy the pairing link below.
|
|
||||||
</Text>
|
|
||||||
<View style={styles.qrCard}>
|
|
||||||
{qrDataUrl ? (
|
|
||||||
<Image source={{ uri: qrDataUrl }} style={styles.qrImage} resizeMode="contain" />
|
|
||||||
) : qrError ? (
|
|
||||||
<Text style={settingsStyles.rowHint}>QR unavailable: {qrError}</Text>
|
|
||||||
) : (
|
|
||||||
<ActivityIndicator size="small" />
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<View style={styles.linkSection}>
|
|
||||||
<Text style={styles.linkLabel}>Pairing link</Text>
|
|
||||||
<Text style={styles.linkText} selectable>
|
|
||||||
{pairingOffer.url}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.modalActions}>
|
|
||||||
<Button variant="outline" size="sm" onPress={onCopyLink}>
|
|
||||||
Copy link
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
actionGroup: {
|
actionGroup: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -659,40 +482,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
gap: theme.spacing[3],
|
gap: theme.spacing[3],
|
||||||
paddingBottom: theme.spacing[2],
|
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: {
|
logOutput: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
Shield,
|
Shield,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
Blocks,
|
Blocks,
|
||||||
|
Smartphone,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
|
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
|
||||||
import type { HostProfile, HostConnection } from "@/types/host-connection";
|
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 { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
|
||||||
import { IntegrationsSection } from "@/desktop/components/integrations-section";
|
import { IntegrationsSection } from "@/desktop/components/integrations-section";
|
||||||
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
|
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
|
||||||
|
import { PairDeviceSection } from "@/desktop/components/pair-device-section";
|
||||||
import { isElectronRuntime } from "@/desktop/host";
|
import { isElectronRuntime } from "@/desktop/host";
|
||||||
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
|
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
|
||||||
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
||||||
@@ -82,7 +84,8 @@ type SettingsSectionId =
|
|||||||
| "diagnostics"
|
| "diagnostics"
|
||||||
| "about"
|
| "about"
|
||||||
| "permissions"
|
| "permissions"
|
||||||
| "daemon";
|
| "daemon"
|
||||||
|
| "pair-device";
|
||||||
|
|
||||||
interface SettingsSectionDef {
|
interface SettingsSectionDef {
|
||||||
id: SettingsSectionId;
|
id: SettingsSectionId;
|
||||||
@@ -101,6 +104,7 @@ function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectio
|
|||||||
if (context.isDesktopApp) {
|
if (context.isDesktopApp) {
|
||||||
sections.push(
|
sections.push(
|
||||||
{ id: "integrations", label: "Integrations", icon: Puzzle },
|
{ id: "integrations", label: "Integrations", icon: Puzzle },
|
||||||
|
{ id: "pair-device", label: "Pair device", icon: Smartphone },
|
||||||
{ id: "daemon", label: "Daemon", icon: Settings },
|
{ id: "daemon", label: "Daemon", icon: Settings },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -646,6 +650,8 @@ function SettingsSectionContent({
|
|||||||
return isDesktopApp ? <IntegrationsSection /> : null;
|
return isDesktopApp ? <IntegrationsSection /> : null;
|
||||||
case "permissions":
|
case "permissions":
|
||||||
return isDesktopApp ? <DesktopPermissionsSection /> : null;
|
return isDesktopApp ? <DesktopPermissionsSection /> : null;
|
||||||
|
case "pair-device":
|
||||||
|
return isDesktopApp ? <PairDeviceSection /> : null;
|
||||||
case "daemon":
|
case "daemon":
|
||||||
return isDesktopApp ? (
|
return isDesktopApp ? (
|
||||||
<LocalDaemonSection appVersion={appVersion} showLifecycleControls={isLocalDaemon} />
|
<LocalDaemonSection appVersion={appVersion} showLifecycleControls={isLocalDaemon} />
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
|
import { promisify } from "node:util";
|
||||||
import {
|
import {
|
||||||
getOrCreateServerId,
|
getOrCreateServerId,
|
||||||
findExecutable,
|
findExecutable,
|
||||||
quoteWindowsCommand,
|
|
||||||
applyProviderEnv,
|
applyProviderEnv,
|
||||||
} from "@getpaseo/server";
|
} from "@getpaseo/server";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
import { tryConnectToDaemon } from "../../utils/client.js";
|
import { tryConnectToDaemon } from "../../utils/client.js";
|
||||||
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
|
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
|
||||||
import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js";
|
import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js";
|
||||||
@@ -171,31 +173,33 @@ const PROVIDER_BINARIES: { label: string; binary: string }[] = [
|
|||||||
{ label: "OpenCode", binary: "opencode" },
|
{ label: "OpenCode", binary: "opencode" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function checkProviderBinary(binary: string): { path: string | null; version: string | null } {
|
async function checkProviderBinary(binary: string): Promise<{ path: string | null; version: string | null }> {
|
||||||
const binaryPath = findExecutable(binary);
|
const binaryPath = await findExecutable(binary);
|
||||||
if (!binaryPath) {
|
if (!binaryPath) {
|
||||||
return { path: null, version: null };
|
return { path: null, version: null };
|
||||||
}
|
}
|
||||||
const env = applyProviderEnv(process.env);
|
const env = applyProviderEnv(process.env);
|
||||||
try {
|
try {
|
||||||
const output = execFileSync(quoteWindowsCommand(binaryPath), ["--version"], {
|
const { stdout } = await execFileAsync(binaryPath, ["--version"], {
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
env,
|
env,
|
||||||
shell: process.platform === "win32",
|
windowsHide: true,
|
||||||
}).trim();
|
});
|
||||||
return { path: binaryPath, version: output || null };
|
return { path: binaryPath, version: stdout.trim() || null };
|
||||||
} catch {
|
} catch {
|
||||||
return { path: binaryPath, version: null };
|
return { path: binaryPath, version: null };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkProviderBinaries(): ProviderBinaryStatus[] {
|
async function checkProviderBinaries(): Promise<ProviderBinaryStatus[]> {
|
||||||
return PROVIDER_BINARIES.map(({ label, binary }) => {
|
const results = await Promise.all(
|
||||||
const result = checkProviderBinary(binary);
|
PROVIDER_BINARIES.map(async ({ label, binary }) => {
|
||||||
return { label, ...result };
|
const result = await checkProviderBinary(binary);
|
||||||
});
|
return { label, ...result };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveOwnerLabel(uid: number | undefined, hostname: string | undefined): string | null {
|
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))}`);
|
note = appendNote(note, `serverId unavailable: ${shortenMessage(normalizeError(error))}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const providers = checkProviderBinaries();
|
const providers = await checkProviderBinaries();
|
||||||
|
|
||||||
const daemonStatus: DaemonStatus = {
|
const daemonStatus: DaemonStatus = {
|
||||||
serverId,
|
serverId,
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
ctx = await createE2ETestContext({ timeout: 45_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanup(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
@@ -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 { readFileSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { app, ipcMain } from "electron";
|
import { app, ipcMain } from "electron";
|
||||||
import log from "electron-log/main";
|
import log from "electron-log/main";
|
||||||
import { resolvePaseoHome } from "@getpaseo/server";
|
import { resolvePaseoHome, spawnProcess } from "@getpaseo/server";
|
||||||
import {
|
import {
|
||||||
copyAttachmentFileToManagedStorage,
|
copyAttachmentFileToManagedStorage,
|
||||||
deleteManagedAttachmentFile,
|
deleteManagedAttachmentFile,
|
||||||
@@ -266,15 +266,11 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
|
|||||||
args: invocation.args,
|
args: invocation.args,
|
||||||
});
|
});
|
||||||
|
|
||||||
const child: ChildProcess = spawn(
|
const child: ChildProcess = spawnProcess(invocation.command, invocation.args, {
|
||||||
invocation.command,
|
detached: true,
|
||||||
invocation.args,
|
env: { ...invocation.env, PASEO_DESKTOP_MANAGED: "1" },
|
||||||
{
|
stdio: ["ignore", "ignore", "ignore"],
|
||||||
detached: true,
|
});
|
||||||
env: { ...invocation.env, PASEO_DESKTOP_MANAGED: "1" },
|
|
||||||
stdio: ["ignore", "ignore", "ignore"],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
logDesktopDaemonLifecycle("detached spawn returned", {
|
logDesktopDaemonLifecycle("detached spawn returned", {
|
||||||
childPid: child.pid ?? null,
|
childPid: child.pid ?? null,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { existsSync, readFileSync } from "node:fs";
|
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 { createRequire } from "node:module";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { app } from "electron";
|
import { app } from "electron";
|
||||||
@@ -234,6 +235,7 @@ export function runCliPassthroughCommand(args: string[]): number {
|
|||||||
const result = spawnSync(invocation.command, invocation.args, {
|
const result = spawnSync(invocation.command, invocation.args, {
|
||||||
env: invocation.env,
|
env: invocation.env,
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
|
windowsHide: true,
|
||||||
});
|
});
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
throw result.error;
|
throw result.error;
|
||||||
@@ -252,7 +254,7 @@ function spawnAsync(
|
|||||||
options: { env: NodeJS.ProcessEnv },
|
options: { env: NodeJS.ProcessEnv },
|
||||||
): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
|
): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const child = spawn(command, args, {
|
const child = spawnProcess(command, args, {
|
||||||
env: options.env,
|
env: options.env,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
});
|
});
|
||||||
@@ -260,10 +262,10 @@ function spawnAsync(
|
|||||||
let stdout = "";
|
let stdout = "";
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
|
|
||||||
child.stdout.on("data", (data: Buffer) => {
|
child.stdout!.on("data", (data: Buffer) => {
|
||||||
stdout += data.toString();
|
stdout += data.toString();
|
||||||
});
|
});
|
||||||
child.stderr.on("data", (data: Buffer) => {
|
child.stderr!.on("data", (data: Buffer) => {
|
||||||
stderr += data.toString();
|
stderr += data.toString();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -200,7 +200,20 @@ export async function installCli(): Promise<InstallStatus> {
|
|||||||
if (await pathOrSymlinkExists(targetPath)) {
|
if (await pathOrSymlinkExists(targetPath)) {
|
||||||
await fs.unlink(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 {
|
} else {
|
||||||
if (await pathOrSymlinkExists(targetPath)) {
|
if (await pathOrSymlinkExists(targetPath)) {
|
||||||
await fs.unlink(targetPath);
|
await fs.unlink(targetPath);
|
||||||
|
|||||||
Reference in New Issue
Block a user