mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
5 Commits
v0.1.110
...
feat/daemo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98c0033f22 | ||
|
|
444214cbe2 | ||
|
|
7f6403f517 | ||
|
|
be8c2a54ce | ||
|
|
c6c030ab26 |
@@ -1745,6 +1745,30 @@ export const ar: TranslationResources = {
|
||||
"فشل في إرسال طلب إعادة التشغيل. يقوم Paseo بإعادة الاتصال تلقائيًا - حاول مرة أخرى بمجرد ظهور المضيف على أنه متصل بالإنترنت.",
|
||||
dialogFailedMessage: "غير قادر على فتح مربع حوار تأكيد إعادة التشغيل.",
|
||||
},
|
||||
update: {
|
||||
title: "Update daemon",
|
||||
hint: "Update the daemon to the latest version and restart it",
|
||||
confirm: "Update",
|
||||
confirmTitle: "Update {{name}}",
|
||||
confirmMessage:
|
||||
"This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
|
||||
updating: "Updating...",
|
||||
phaseStarting: "Preparing update...",
|
||||
phaseDownloading: "Downloading packages...",
|
||||
phaseInstalling: "Installing...",
|
||||
phaseComplete: "Update complete, restarting...",
|
||||
unableToReconnectTitle: "Unable to reconnect",
|
||||
unableToReconnectMessage:
|
||||
"{{name}} did not come back online after updating. Please verify the daemon restarted.",
|
||||
unavailableTitle: "Host unavailable",
|
||||
unavailableMessage:
|
||||
"This host is not connected. Wait for it to come online before updating.",
|
||||
offlineTitle: "Host offline",
|
||||
offlineMessage: "This host is offline. Wait until it is back online before updating.",
|
||||
requestFailedTitle: "Update failed",
|
||||
requestFailedMessage: "Failed to update the daemon: {{error}}",
|
||||
dialogFailedMessage: "Unable to open the update confirmation dialog.",
|
||||
},
|
||||
dangerZone: "منطقة الخطر",
|
||||
remove: {
|
||||
title: "إزالة المضيف",
|
||||
|
||||
@@ -1753,6 +1753,30 @@ export const en = {
|
||||
"Failed to send the restart request. Paseo reconnects automatically-try again once the host shows as online.",
|
||||
dialogFailedMessage: "Unable to open the restart confirmation dialog.",
|
||||
},
|
||||
update: {
|
||||
title: "Update daemon",
|
||||
hint: "Update the daemon to the latest version and restart it",
|
||||
confirm: "Update",
|
||||
confirmTitle: "Update {{name}}",
|
||||
confirmMessage:
|
||||
"This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
|
||||
updating: "Updating...",
|
||||
phaseStarting: "Preparing update...",
|
||||
phaseDownloading: "Downloading packages...",
|
||||
phaseInstalling: "Installing...",
|
||||
phaseComplete: "Update complete, restarting...",
|
||||
unableToReconnectTitle: "Unable to reconnect",
|
||||
unableToReconnectMessage:
|
||||
"{{name}} did not come back online after updating. Please verify the daemon restarted.",
|
||||
unavailableTitle: "Host unavailable",
|
||||
unavailableMessage:
|
||||
"This host is not connected. Wait for it to come online before updating.",
|
||||
offlineTitle: "Host offline",
|
||||
offlineMessage: "This host is offline. Wait until it is back online before updating.",
|
||||
requestFailedTitle: "Update failed",
|
||||
requestFailedMessage: "Failed to update the daemon: {{error}}",
|
||||
dialogFailedMessage: "Unable to open the update confirmation dialog.",
|
||||
},
|
||||
dangerZone: "Danger zone",
|
||||
remove: {
|
||||
title: "Remove host",
|
||||
|
||||
@@ -1786,6 +1786,30 @@ export const es: TranslationResources = {
|
||||
dialogFailedMessage:
|
||||
"No se puede abrir el cuadro de diálogo de confirmación de reinicio.",
|
||||
},
|
||||
update: {
|
||||
title: "Update daemon",
|
||||
hint: "Update the daemon to the latest version and restart it",
|
||||
confirm: "Update",
|
||||
confirmTitle: "Update {{name}}",
|
||||
confirmMessage:
|
||||
"This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
|
||||
updating: "Updating...",
|
||||
phaseStarting: "Preparing update...",
|
||||
phaseDownloading: "Downloading packages...",
|
||||
phaseInstalling: "Installing...",
|
||||
phaseComplete: "Update complete, restarting...",
|
||||
unableToReconnectTitle: "Unable to reconnect",
|
||||
unableToReconnectMessage:
|
||||
"{{name}} did not come back online after updating. Please verify the daemon restarted.",
|
||||
unavailableTitle: "Host unavailable",
|
||||
unavailableMessage:
|
||||
"This host is not connected. Wait for it to come online before updating.",
|
||||
offlineTitle: "Host offline",
|
||||
offlineMessage: "This host is offline. Wait until it is back online before updating.",
|
||||
requestFailedTitle: "Update failed",
|
||||
requestFailedMessage: "Failed to update the daemon: {{error}}",
|
||||
dialogFailedMessage: "Unable to open the update confirmation dialog.",
|
||||
},
|
||||
dangerZone: "Zona de peligro",
|
||||
remove: {
|
||||
title: "Eliminar host",
|
||||
|
||||
@@ -1791,6 +1791,30 @@ export const fr: TranslationResources = {
|
||||
dialogFailedMessage:
|
||||
"Impossible d'ouvrir la boîte de dialogue de confirmation de redémarrage.",
|
||||
},
|
||||
update: {
|
||||
title: "Update daemon",
|
||||
hint: "Update the daemon to the latest version and restart it",
|
||||
confirm: "Update",
|
||||
confirmTitle: "Update {{name}}",
|
||||
confirmMessage:
|
||||
"This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
|
||||
updating: "Updating...",
|
||||
phaseStarting: "Preparing update...",
|
||||
phaseDownloading: "Downloading packages...",
|
||||
phaseInstalling: "Installing...",
|
||||
phaseComplete: "Update complete, restarting...",
|
||||
unableToReconnectTitle: "Unable to reconnect",
|
||||
unableToReconnectMessage:
|
||||
"{{name}} did not come back online after updating. Please verify the daemon restarted.",
|
||||
unavailableTitle: "Host unavailable",
|
||||
unavailableMessage:
|
||||
"This host is not connected. Wait for it to come online before updating.",
|
||||
offlineTitle: "Host offline",
|
||||
offlineMessage: "This host is offline. Wait until it is back online before updating.",
|
||||
requestFailedTitle: "Update failed",
|
||||
requestFailedMessage: "Failed to update the daemon: {{error}}",
|
||||
dialogFailedMessage: "Unable to open the update confirmation dialog.",
|
||||
},
|
||||
dangerZone: "Zone dangereuse",
|
||||
remove: {
|
||||
title: "Supprimer l'hôte",
|
||||
|
||||
@@ -1765,6 +1765,31 @@ export const ja: TranslationResources = {
|
||||
"再起動リクエストの送信に失敗しました。Paseoは自動的に再接続します。ホストがオンラインになったら再試行してください。",
|
||||
dialogFailedMessage: "再起動確認ダイアログを開けませんでした。",
|
||||
},
|
||||
update: {
|
||||
title: "デーモンを更新",
|
||||
hint: "デーモンを最新バージョンに更新して再起動します",
|
||||
confirm: "更新",
|
||||
confirmTitle: "{{name}}を更新",
|
||||
confirmMessage:
|
||||
"これによりデーモンが最新バージョンに更新され、再起動されます。実行中のエージェントは一時的に中断されます。",
|
||||
updating: "更新中...",
|
||||
phaseStarting: "更新を準備中...",
|
||||
phaseDownloading: "パッケージをダウンロード中...",
|
||||
phaseInstalling: "インストール中...",
|
||||
phaseComplete: "更新が完了しました。再起動中...",
|
||||
unableToReconnectTitle: "再接続できません",
|
||||
unableToReconnectMessage:
|
||||
"{{name}}が更新後にオンラインに戻りませんでした。デーモンが再起動されたことを確認してください。",
|
||||
unavailableTitle: "ホストが利用できません",
|
||||
unavailableMessage:
|
||||
"このホストは接続されていません。更新する前にオンラインになるまでお待ちください。",
|
||||
offlineTitle: "ホストオフライン",
|
||||
offlineMessage:
|
||||
"このホストはオフラインです。オンラインに戻るまで待ってから更新してください。",
|
||||
requestFailedTitle: "更新に失敗しました",
|
||||
requestFailedMessage: "デーモンの更新に失敗しました: {{error}}",
|
||||
dialogFailedMessage: "更新確認ダイアログを開けませんでした。",
|
||||
},
|
||||
dangerZone: "危険ゾーン",
|
||||
remove: {
|
||||
title: "ホストを削除",
|
||||
|
||||
@@ -1771,6 +1771,31 @@ export const ptBR: TranslationResources = {
|
||||
"Falha ao enviar a solicitação de reinício. O Paseo reconecta automaticamente. Tente novamente quando o host aparecer como online.",
|
||||
dialogFailedMessage: "Não foi possível abrir o diálogo de confirmação de reinício.",
|
||||
},
|
||||
update: {
|
||||
title: "Atualizar daemon",
|
||||
hint: "Atualiza o daemon para a versão mais recente e o reinicia",
|
||||
confirm: "Atualizar",
|
||||
confirmTitle: "Atualizar {{name}}",
|
||||
confirmMessage:
|
||||
"Isso vai atualizar o daemon para a versão mais recente e reiniciá-lo. Agentes em execução serão interrompidos brevemente.",
|
||||
updating: "Atualizando...",
|
||||
phaseStarting: "Preparando atualização...",
|
||||
phaseDownloading: "Baixando pacotes...",
|
||||
phaseInstalling: "Instalando...",
|
||||
phaseComplete: "Atualização concluída, reiniciando...",
|
||||
unableToReconnectTitle: "Não foi possível reconectar",
|
||||
unableToReconnectMessage:
|
||||
"{{name}} não voltou a ficar online após a atualização. Verifique se o daemon reiniciou.",
|
||||
unavailableTitle: "Host indisponível",
|
||||
unavailableMessage:
|
||||
"Este host não está conectado. Aguarde até ele ficar online antes de atualizar.",
|
||||
offlineTitle: "Host offline",
|
||||
offlineMessage:
|
||||
"Este host está offline. Aguarde até ele voltar a ficar online antes de atualizar.",
|
||||
requestFailedTitle: "Falha na atualização",
|
||||
requestFailedMessage: "Falha ao atualizar o daemon: {{error}}",
|
||||
dialogFailedMessage: "Não foi possível abrir o diálogo de confirmação da atualização.",
|
||||
},
|
||||
dangerZone: "Zona de perigo",
|
||||
remove: {
|
||||
title: "Remover host",
|
||||
|
||||
@@ -1777,6 +1777,30 @@ export const ru: TranslationResources = {
|
||||
"Не удалось отправить запрос на перезапуск.Paseo автоматически повторно подключается. Повторите попытку, как только хост окажется в сети.",
|
||||
dialogFailedMessage: "Невозможно открыть диалоговое окно подтверждения перезапуска.",
|
||||
},
|
||||
update: {
|
||||
title: "Update daemon",
|
||||
hint: "Update the daemon to the latest version and restart it",
|
||||
confirm: "Update",
|
||||
confirmTitle: "Update {{name}}",
|
||||
confirmMessage:
|
||||
"This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
|
||||
updating: "Updating...",
|
||||
phaseStarting: "Preparing update...",
|
||||
phaseDownloading: "Downloading packages...",
|
||||
phaseInstalling: "Installing...",
|
||||
phaseComplete: "Update complete, restarting...",
|
||||
unableToReconnectTitle: "Unable to reconnect",
|
||||
unableToReconnectMessage:
|
||||
"{{name}} did not come back online after updating. Please verify the daemon restarted.",
|
||||
unavailableTitle: "Host unavailable",
|
||||
unavailableMessage:
|
||||
"This host is not connected. Wait for it to come online before updating.",
|
||||
offlineTitle: "Host offline",
|
||||
offlineMessage: "This host is offline. Wait until it is back online before updating.",
|
||||
requestFailedTitle: "Update failed",
|
||||
requestFailedMessage: "Failed to update the daemon: {{error}}",
|
||||
dialogFailedMessage: "Unable to open the update confirmation dialog.",
|
||||
},
|
||||
dangerZone: "Опасная зона",
|
||||
remove: {
|
||||
title: "Удалить хост",
|
||||
|
||||
@@ -1722,6 +1722,30 @@ export const zhCN: TranslationResources = {
|
||||
requestFailedMessage: "发送重启请求失败。Paseo 会自动重连,请在 Host 显示在线后重试。",
|
||||
dialogFailedMessage: "无法打开重启确认对话框。",
|
||||
},
|
||||
update: {
|
||||
title: "Update daemon",
|
||||
hint: "Update the daemon to the latest version and restart it",
|
||||
confirm: "Update",
|
||||
confirmTitle: "Update {{name}}",
|
||||
confirmMessage:
|
||||
"This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
|
||||
updating: "Updating...",
|
||||
phaseStarting: "Preparing update...",
|
||||
phaseDownloading: "Downloading packages...",
|
||||
phaseInstalling: "Installing...",
|
||||
phaseComplete: "Update complete, restarting...",
|
||||
unableToReconnectTitle: "Unable to reconnect",
|
||||
unableToReconnectMessage:
|
||||
"{{name}} did not come back online after updating. Please verify the daemon restarted.",
|
||||
unavailableTitle: "Host unavailable",
|
||||
unavailableMessage:
|
||||
"This host is not connected. Wait for it to come online before updating.",
|
||||
offlineTitle: "Host offline",
|
||||
offlineMessage: "This host is offline. Wait until it is back online before updating.",
|
||||
requestFailedTitle: "Update failed",
|
||||
requestFailedMessage: "Failed to update the daemon: {{error}}",
|
||||
dialogFailedMessage: "Unable to open the update confirmation dialog.",
|
||||
},
|
||||
dangerZone: "危险区域",
|
||||
remove: {
|
||||
title: "移除 Host",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowUpToLine,
|
||||
ChevronRight,
|
||||
Globe,
|
||||
Monitor,
|
||||
@@ -53,6 +54,8 @@ import { useSessionStore } from "@/stores/session-store";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import type { HostConnection, HostProfile } from "@/types/host-connection";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
|
||||
import { resolveAppVersion } from "@/utils/app-version";
|
||||
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
|
||||
import { formatLatency } from "@/utils/latency";
|
||||
import { ICON_SIZE } from "@/styles/theme";
|
||||
@@ -353,6 +356,8 @@ export function HostSettingsPage({
|
||||
|
||||
{isLocalDaemon ? <LocalDaemonSection /> : null}
|
||||
|
||||
{!isLocalDaemon ? <UpdateDaemonCard host={host} /> : null}
|
||||
|
||||
<RemoveHostSection host={host} isLocalDaemon={isLocalDaemon} onRemoved={onHostRemoved} />
|
||||
</View>
|
||||
);
|
||||
@@ -709,6 +714,214 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateDaemonCard({ host }: { host: HostProfile }) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const daemonClient = useHostRuntimeClient(host.serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(host.serverId);
|
||||
const runtime = getHostRuntimeStore();
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [progressPhase, setProgressPhase] = useState<string | null>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
const unsubscribeRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const daemonVersion = useSessionStore(
|
||||
(state) => state.sessions[host.serverId]?.serverInfo?.version ?? null,
|
||||
);
|
||||
const supportsSelfUpdate = useSessionStore(
|
||||
(state) => state.sessions[host.serverId]?.serverInfo?.features?.daemonSelfUpdate === true,
|
||||
);
|
||||
|
||||
const appVersion = resolveAppVersion();
|
||||
const hasVersionMismatch = isVersionMismatch(appVersion, daemonVersion);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
unsubscribeRef.current?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isHostConnected = useCallback(
|
||||
() => isHostRuntimeConnected(runtime.getSnapshot(host.serverId)),
|
||||
[host.serverId, runtime],
|
||||
);
|
||||
const hasReconnectedAfter = useCallback(
|
||||
(startGeneration: number | null) => {
|
||||
const snapshot = runtime.getSnapshot(host.serverId);
|
||||
if (!snapshot || !isHostRuntimeConnected(snapshot)) return false;
|
||||
return startGeneration === null || snapshot.clientGeneration !== startGeneration;
|
||||
},
|
||||
[host.serverId, runtime],
|
||||
);
|
||||
|
||||
const waitForCondition = useCallback(
|
||||
async (predicate: () => boolean, timeoutMs: number, intervalMs = 250) => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (!isMountedRef.current) return false;
|
||||
if (predicate()) return true;
|
||||
await delay(intervalMs);
|
||||
}
|
||||
return predicate();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const waitForDaemonRestart = useCallback(
|
||||
async (startGeneration: number | null) => {
|
||||
const disconnectTimeoutMs = 15000;
|
||||
const reconnectTimeoutMs = 120000; // 2 minutes — npm update + restart can take a while
|
||||
if (!hasReconnectedAfter(startGeneration) && isHostConnected()) {
|
||||
await waitForCondition(
|
||||
() => !isHostConnected() || hasReconnectedAfter(startGeneration),
|
||||
disconnectTimeoutMs,
|
||||
);
|
||||
}
|
||||
const reconnected =
|
||||
hasReconnectedAfter(startGeneration) ||
|
||||
(await waitForCondition(() => hasReconnectedAfter(startGeneration), reconnectTimeoutMs));
|
||||
if (isMountedRef.current) {
|
||||
setIsUpdating(false);
|
||||
setProgressPhase(null);
|
||||
if (!reconnected) {
|
||||
Alert.alert(
|
||||
t("settings.host.daemon.update.unableToReconnectTitle"),
|
||||
t("settings.host.daemon.update.unableToReconnectMessage", { name: host.label }),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[hasReconnectedAfter, host.label, isHostConnected, t, waitForCondition],
|
||||
);
|
||||
|
||||
const handleUpdate = useCallback(() => {
|
||||
if (!daemonClient) {
|
||||
Alert.alert(
|
||||
t("settings.host.daemon.update.unavailableTitle"),
|
||||
t("settings.host.daemon.update.unavailableMessage"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!isHostConnected()) {
|
||||
Alert.alert(
|
||||
t("settings.host.daemon.update.offlineTitle"),
|
||||
t("settings.host.daemon.update.offlineMessage"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
void confirmDialog({
|
||||
title: t("settings.host.daemon.update.confirmTitle", { name: host.label }),
|
||||
message: t("settings.host.daemon.update.confirmMessage"),
|
||||
confirmLabel: t("settings.host.daemon.update.confirm"),
|
||||
cancelLabel: t("common.actions.cancel"),
|
||||
destructive: false,
|
||||
})
|
||||
.then((confirmed) => {
|
||||
if (!confirmed) return;
|
||||
const startGeneration = runtime.getSnapshot(host.serverId)?.clientGeneration ?? null;
|
||||
setIsUpdating(true);
|
||||
setProgressPhase(t("settings.host.daemon.update.phaseStarting"));
|
||||
const requestId = `settings_daemon_update_${host.serverId}`;
|
||||
|
||||
const unsubscribe = daemonClient.on("daemon.update.progress", (message) => {
|
||||
if (message.payload.requestId !== requestId) return;
|
||||
if (!isMountedRef.current) return;
|
||||
const { phase } = message.payload;
|
||||
if (phase === "starting")
|
||||
setProgressPhase(t("settings.host.daemon.update.phaseStarting"));
|
||||
else if (phase === "downloading")
|
||||
setProgressPhase(t("settings.host.daemon.update.phaseDownloading"));
|
||||
else if (phase === "installing")
|
||||
setProgressPhase(t("settings.host.daemon.update.phaseInstalling"));
|
||||
else if (phase === "complete")
|
||||
setProgressPhase(t("settings.host.daemon.update.phaseComplete"));
|
||||
});
|
||||
unsubscribeRef.current = unsubscribe;
|
||||
|
||||
void daemonClient
|
||||
.updateDaemon(requestId)
|
||||
.then((response) => {
|
||||
unsubscribeRef.current = null;
|
||||
unsubscribe();
|
||||
if (!response.success) {
|
||||
if (!isMountedRef.current) return undefined;
|
||||
setIsUpdating(false);
|
||||
setProgressPhase(null);
|
||||
Alert.alert(
|
||||
t("settings.host.daemon.update.requestFailedTitle"),
|
||||
t("settings.host.daemon.update.requestFailedMessage", {
|
||||
error: response.error ?? "Unknown error",
|
||||
}),
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
// Update succeeded — wait for daemon to restart and reconnect
|
||||
void waitForDaemonRestart(startGeneration);
|
||||
return undefined;
|
||||
})
|
||||
.catch((error) => {
|
||||
unsubscribeRef.current = null;
|
||||
unsubscribe();
|
||||
console.error(`[HostPage] Failed to update daemon ${host.label}`, error);
|
||||
if (!isMountedRef.current) return;
|
||||
setIsUpdating(false);
|
||||
setProgressPhase(null);
|
||||
Alert.alert(
|
||||
t("settings.host.daemon.update.requestFailedTitle"),
|
||||
t("settings.host.daemon.update.requestFailedMessage", {
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
}),
|
||||
);
|
||||
});
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`[HostPage] Failed to open update confirmation for ${host.label}`, error);
|
||||
Alert.alert(
|
||||
t("settings.host.daemon.update.requestFailedTitle"),
|
||||
t("settings.host.daemon.update.dialogFailedMessage"),
|
||||
);
|
||||
});
|
||||
}, [daemonClient, host.label, host.serverId, isHostConnected, runtime, t, waitForDaemonRestart]);
|
||||
|
||||
const updateIcon = useMemo(
|
||||
() => <ArrowUpToLine size={theme.iconSize.sm} color={theme.colors.foreground} />,
|
||||
[theme.iconSize.sm, theme.colors.foreground],
|
||||
);
|
||||
|
||||
// Don't show if the daemon doesn't support self-update or versions match
|
||||
if (!supportsSelfUpdate || !hasVersionMismatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const buttonLabel = isUpdating
|
||||
? (progressPhase ?? t("settings.host.daemon.update.updating"))
|
||||
: t("settings.host.daemon.update.confirm");
|
||||
|
||||
return (
|
||||
<View style={settingsStyles.card} testID="host-page-update-card">
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.host.daemon.update.title")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{t("settings.host.daemon.update.hint")}</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={updateIcon}
|
||||
onPress={handleUpdate}
|
||||
disabled={isUpdating || !daemonClient || !isConnected}
|
||||
testID="host-page-update-button"
|
||||
>
|
||||
{buttonLabel}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function InjectPaseoToolsCard({ serverId }: { serverId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
RenameTerminalResponseSchema,
|
||||
RestartRequestedStatusPayloadSchema,
|
||||
ShutdownRequestedStatusPayloadSchema,
|
||||
DaemonUpdateResponseSchema,
|
||||
SessionInboundMessageSchema,
|
||||
type ServerInfoStatusPayload,
|
||||
WSOutboundMessageSchema,
|
||||
@@ -468,6 +469,7 @@ export interface FetchAgentTimelineOptions {
|
||||
type AgentRefreshedStatusPayload = z.infer<typeof AgentRefreshedStatusPayloadSchema>;
|
||||
type RestartRequestedStatusPayload = z.infer<typeof RestartRequestedStatusPayloadSchema>;
|
||||
type ShutdownRequestedStatusPayload = z.infer<typeof ShutdownRequestedStatusPayloadSchema>;
|
||||
type DaemonUpdateResponse = z.infer<typeof DaemonUpdateResponseSchema>;
|
||||
type FetchAgentsPayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "fetch_agents_response" }
|
||||
@@ -2636,6 +2638,30 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async updateDaemon(requestId?: string): Promise<DaemonUpdateResponse["payload"]> {
|
||||
const resolvedRequestId = this.createRequestId(requestId);
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "daemon.update.request",
|
||||
requestId: resolvedRequestId,
|
||||
});
|
||||
return this.sendRequest({
|
||||
requestId: resolvedRequestId,
|
||||
message,
|
||||
timeout: 300_000, // 5 minutes — npm update can be slow on remote machines
|
||||
options: { skipQueue: true },
|
||||
select: (msg) => {
|
||||
const parsed = DaemonUpdateResponseSchema.safeParse(msg);
|
||||
if (!parsed.success) {
|
||||
return null;
|
||||
}
|
||||
if (parsed.data.payload.requestId !== resolvedRequestId) {
|
||||
return null;
|
||||
}
|
||||
return parsed.data.payload;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Audio / Voice
|
||||
// ============================================================================
|
||||
|
||||
@@ -402,3 +402,23 @@ describe("paseo worktree archive request compatibility", () => {
|
||||
expect(parsed.scope).toBe("workspace");
|
||||
});
|
||||
});
|
||||
|
||||
describe("daemon update messages", () => {
|
||||
test("daemon update progress is a scoped outbound message", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "daemon.update.progress",
|
||||
payload: {
|
||||
requestId: "update-1",
|
||||
phase: "installing",
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed).toEqual({
|
||||
type: "daemon.update.progress",
|
||||
payload: {
|
||||
requestId: "update-1",
|
||||
phase: "installing",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1252,6 +1252,11 @@ export const ShutdownServerRequestMessageSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const DaemonUpdateRequestMessageSchema = z.object({
|
||||
type: z.literal("daemon.update.request"),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const AgentTimelineCursorSchema = z.object({
|
||||
epoch: z.string(),
|
||||
seq: z.number().int().nonnegative(),
|
||||
@@ -2053,6 +2058,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
CancelAgentRequestMessageSchema,
|
||||
ShutdownServerRequestMessageSchema,
|
||||
RestartServerRequestMessageSchema,
|
||||
DaemonUpdateRequestMessageSchema,
|
||||
FetchAgentTimelineRequestMessageSchema,
|
||||
SetAgentModeRequestMessageSchema,
|
||||
SetAgentModelRequestMessageSchema,
|
||||
@@ -2327,6 +2333,8 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
agentDetach: z.boolean().optional(),
|
||||
// COMPAT(daemonDiagnostics): added in v0.1.100, remove gate after 2026-12-25 once daemon floor >= v0.1.100.
|
||||
daemonDiagnostics: z.boolean().optional(),
|
||||
// COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13.
|
||||
daemonSelfUpdate: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
@@ -4093,6 +4101,29 @@ export const TerminalAttentionRequiredSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const DaemonUpdateResponseSchema = z.object({
|
||||
type: z.literal("daemon.update.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
success: z.boolean(),
|
||||
error: z.string().nullable(),
|
||||
previousVersion: z.string().nullable(),
|
||||
newVersion: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type DaemonUpdateResponse = z.infer<typeof DaemonUpdateResponseSchema>;
|
||||
|
||||
export const DaemonUpdateProgressMessageSchema = z.object({
|
||||
type: z.literal("daemon.update.progress"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
phase: z.enum(["starting", "downloading", "installing", "complete"]),
|
||||
}),
|
||||
});
|
||||
|
||||
export type DaemonUpdateProgressMessage = z.infer<typeof DaemonUpdateProgressMessageSchema>;
|
||||
|
||||
export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ActivityLogMessageSchema,
|
||||
AssistantChunkMessageSchema,
|
||||
@@ -4228,6 +4259,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
LoopInspectResponseSchema,
|
||||
LoopLogsResponseSchema,
|
||||
LoopStopResponseSchema,
|
||||
DaemonUpdateProgressMessageSchema,
|
||||
DaemonUpdateResponseSchema,
|
||||
]);
|
||||
|
||||
export type SessionOutboundMessage = z.infer<typeof SessionOutboundMessageSchema>;
|
||||
|
||||
@@ -777,7 +777,9 @@ export class Session {
|
||||
this.daemonSession = new DaemonSession({
|
||||
host: {
|
||||
emit: (msg) => this.emit(msg),
|
||||
emitLifecycleIntent: (intent) => this.emitLifecycleIntent(intent),
|
||||
},
|
||||
clientId: this.clientId,
|
||||
paseoHome: this.paseoHome,
|
||||
serverId,
|
||||
daemonVersion,
|
||||
@@ -1510,6 +1512,8 @@ export class Session {
|
||||
return this.daemonSession.handleGetPairingOfferRequest(msg);
|
||||
case "diagnostics.request":
|
||||
return this.daemonSession.handleDiagnosticsRequest(msg);
|
||||
case "daemon.update.request":
|
||||
return this.daemonSession.handleUpdateRequest(msg);
|
||||
case "set_daemon_config_request":
|
||||
this.emit({
|
||||
type: "set_daemon_config_response",
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
|
||||
import {
|
||||
DaemonSelfUpdateInProgressError,
|
||||
type DaemonSelfUpdateInput,
|
||||
type DaemonSelfUpdater,
|
||||
} from "./daemon-self-updater.js";
|
||||
import {
|
||||
DaemonSelfUpdateSessionController,
|
||||
type DaemonSelfUpdateSessionControllerOptions,
|
||||
} from "./daemon-self-update-session-controller.js";
|
||||
|
||||
type TestUpdater = Pick<DaemonSelfUpdater, "update">;
|
||||
type RestartIntent = Parameters<DaemonSelfUpdateSessionControllerOptions["emitLifecycleIntent"]>[0];
|
||||
|
||||
interface ControllerHarness {
|
||||
controller: DaemonSelfUpdateSessionController;
|
||||
emitted: SessionOutboundMessage[];
|
||||
restartIntents: RestartIntent[];
|
||||
}
|
||||
|
||||
function createController(input: {
|
||||
updater: TestUpdater;
|
||||
daemonVersion?: string | null;
|
||||
}): ControllerHarness {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const restartIntents: RestartIntent[] = [];
|
||||
const controller = new DaemonSelfUpdateSessionController({
|
||||
clientId: "client-1",
|
||||
daemonVersion: input.daemonVersion ?? "0.1.15",
|
||||
emit: (msg) => {
|
||||
emitted.push(msg);
|
||||
},
|
||||
emitLifecycleIntent: (intent) => {
|
||||
restartIntents.push(intent);
|
||||
},
|
||||
sessionLogger: createTestLogger(),
|
||||
updater: input.updater,
|
||||
});
|
||||
|
||||
return { controller, emitted, restartIntents };
|
||||
}
|
||||
|
||||
const updateRequest: SessionInboundMessage = {
|
||||
type: "daemon.update.request",
|
||||
requestId: "update-1",
|
||||
};
|
||||
|
||||
describe("DaemonSelfUpdateSessionController", () => {
|
||||
test("returns undefined synchronously for messages owned by another subsystem", () => {
|
||||
const updater: TestUpdater = {
|
||||
async update() {
|
||||
throw new Error("update should not run");
|
||||
},
|
||||
};
|
||||
const { controller, emitted, restartIntents } = createController({ updater });
|
||||
const message: SessionInboundMessage = {
|
||||
type: "daemon.get_status.request",
|
||||
requestId: "status-1",
|
||||
};
|
||||
|
||||
const result = controller.dispatch(message);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(emitted).toEqual([]);
|
||||
expect(restartIntents).toEqual([]);
|
||||
});
|
||||
|
||||
test("emits progress, response, and restart lifecycle intent after a successful update", async () => {
|
||||
let updateInput: DaemonSelfUpdateInput | null = null;
|
||||
const updater: TestUpdater = {
|
||||
async update(input) {
|
||||
updateInput = input;
|
||||
input.onProgress("starting");
|
||||
input.onProgress("installing");
|
||||
return { success: true, error: null, newVersion: "0.1.96" };
|
||||
},
|
||||
};
|
||||
const { controller, emitted, restartIntents } = createController({ updater });
|
||||
|
||||
await controller.dispatch(updateRequest);
|
||||
|
||||
expect(updateInput?.daemonVersion).toBe("0.1.15");
|
||||
expect(emitted).toEqual([
|
||||
{
|
||||
type: "daemon.update.progress",
|
||||
payload: {
|
||||
requestId: "update-1",
|
||||
phase: "starting",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "daemon.update.progress",
|
||||
payload: {
|
||||
requestId: "update-1",
|
||||
phase: "installing",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "daemon.update.response",
|
||||
payload: {
|
||||
requestId: "update-1",
|
||||
success: true,
|
||||
error: null,
|
||||
previousVersion: "0.1.15",
|
||||
newVersion: "0.1.96",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(restartIntents).toEqual([
|
||||
{
|
||||
type: "restart",
|
||||
clientId: "client-1",
|
||||
requestId: "update-1",
|
||||
reason: "daemon_update",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("emits a failed response without restart lifecycle intent", async () => {
|
||||
const updater: TestUpdater = {
|
||||
async update() {
|
||||
return { success: false, error: "not an npm global install", newVersion: null };
|
||||
},
|
||||
};
|
||||
const { controller, emitted, restartIntents } = createController({ updater });
|
||||
|
||||
await controller.dispatch(updateRequest);
|
||||
|
||||
expect(emitted).toEqual([
|
||||
{
|
||||
type: "daemon.update.response",
|
||||
payload: {
|
||||
requestId: "update-1",
|
||||
success: false,
|
||||
error: "not an npm global install",
|
||||
previousVersion: "0.1.15",
|
||||
newVersion: null,
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(restartIntents).toEqual([]);
|
||||
});
|
||||
|
||||
test("maps concurrent updates to rpc_error", async () => {
|
||||
const updater: TestUpdater = {
|
||||
async update() {
|
||||
throw new DaemonSelfUpdateInProgressError();
|
||||
},
|
||||
};
|
||||
const { controller, emitted, restartIntents } = createController({ updater });
|
||||
|
||||
await controller.dispatch(updateRequest);
|
||||
|
||||
expect(emitted).toEqual([
|
||||
{
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId: "update-1",
|
||||
requestType: "daemon.update.request",
|
||||
error: "An update is already in progress",
|
||||
code: "already_updating",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(restartIntents).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import type pino from "pino";
|
||||
import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
|
||||
import {
|
||||
daemonSelfUpdater,
|
||||
DaemonSelfUpdateInProgressError,
|
||||
type DaemonSelfUpdatePhase,
|
||||
type DaemonSelfUpdater,
|
||||
} from "./daemon-self-updater.js";
|
||||
import { getErrorMessage } from "@getpaseo/protocol/error-utils";
|
||||
|
||||
type DaemonUpdateRequest = Extract<SessionInboundMessage, { type: "daemon.update.request" }>;
|
||||
|
||||
const DAEMON_SELF_UPDATE_MESSAGE_TYPES: ReadonlySet<SessionInboundMessage["type"]> = new Set([
|
||||
"daemon.update.request",
|
||||
]);
|
||||
|
||||
interface DaemonSelfUpdateRestartIntent {
|
||||
type: "restart";
|
||||
clientId: string;
|
||||
requestId: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface DaemonSelfUpdateSessionControllerOptions {
|
||||
clientId: string;
|
||||
daemonVersion: string | null;
|
||||
emit: (msg: SessionOutboundMessage) => void;
|
||||
emitLifecycleIntent: (intent: DaemonSelfUpdateRestartIntent) => void;
|
||||
sessionLogger: pino.Logger;
|
||||
updater?: Pick<DaemonSelfUpdater, "update">;
|
||||
}
|
||||
|
||||
function isDaemonSelfUpdateMessage(msg: SessionInboundMessage): msg is DaemonUpdateRequest {
|
||||
return DAEMON_SELF_UPDATE_MESSAGE_TYPES.has(msg.type);
|
||||
}
|
||||
|
||||
export class DaemonSelfUpdateSessionController {
|
||||
private readonly clientId: string;
|
||||
private readonly daemonVersion: string | null;
|
||||
private readonly emit: (msg: SessionOutboundMessage) => void;
|
||||
private readonly emitLifecycleIntent: (intent: DaemonSelfUpdateRestartIntent) => void;
|
||||
private readonly sessionLogger: pino.Logger;
|
||||
private readonly updater: Pick<DaemonSelfUpdater, "update">;
|
||||
|
||||
constructor(options: DaemonSelfUpdateSessionControllerOptions) {
|
||||
this.clientId = options.clientId;
|
||||
this.daemonVersion = options.daemonVersion;
|
||||
this.emit = options.emit;
|
||||
this.emitLifecycleIntent = options.emitLifecycleIntent;
|
||||
this.sessionLogger = options.sessionLogger;
|
||||
this.updater = options.updater ?? daemonSelfUpdater;
|
||||
}
|
||||
|
||||
dispatch(msg: SessionInboundMessage): Promise<void> | undefined {
|
||||
if (!isDaemonSelfUpdateMessage(msg)) {
|
||||
return undefined;
|
||||
}
|
||||
return this.handleDaemonUpdateRequest(msg);
|
||||
}
|
||||
|
||||
private async handleDaemonUpdateRequest(msg: DaemonUpdateRequest): Promise<void> {
|
||||
const previousVersion = this.daemonVersion;
|
||||
|
||||
try {
|
||||
const result = await this.updater.update({
|
||||
daemonVersion: previousVersion,
|
||||
onProgress: (phase) => this.emitProgress(msg.requestId, phase),
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
|
||||
this.emitResponse({
|
||||
requestId: msg.requestId,
|
||||
success: result.success,
|
||||
error: result.error,
|
||||
previousVersion,
|
||||
newVersion: result.newVersion,
|
||||
});
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.emitLifecycleIntent({
|
||||
type: "restart",
|
||||
clientId: this.clientId,
|
||||
requestId: msg.requestId,
|
||||
reason: "daemon_update",
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof DaemonSelfUpdateInProgressError) {
|
||||
this.emit({
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
requestType: "daemon.update.request",
|
||||
error: error.message,
|
||||
code: "already_updating",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.sessionLogger.error({ err: error }, "Daemon update failed with exception");
|
||||
this.emitResponse({
|
||||
requestId: msg.requestId,
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
previousVersion,
|
||||
newVersion: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private emitProgress(requestId: string, phase: DaemonSelfUpdatePhase): void {
|
||||
this.emit({
|
||||
type: "daemon.update.progress",
|
||||
payload: {
|
||||
requestId,
|
||||
phase,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private emitResponse(payload: {
|
||||
requestId: string;
|
||||
success: boolean;
|
||||
error: string | null;
|
||||
previousVersion: string | null;
|
||||
newVersion: string | null;
|
||||
}): void {
|
||||
this.emit({
|
||||
type: "daemon.update.response",
|
||||
payload,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
DaemonSelfUpdateInProgressError,
|
||||
DaemonSelfUpdater,
|
||||
type DaemonSelfUpdateRuntime,
|
||||
type DaemonSelfUpdatePhase,
|
||||
} from "./daemon-self-updater.js";
|
||||
import type { CommandResult, NpmGlobalPaseoInstall } from "./npm-global-cli.js";
|
||||
|
||||
interface TestLogger {
|
||||
errors: Array<{ obj: object; msg?: string }>;
|
||||
warnings: Array<{ obj: object; msg?: string }>;
|
||||
error(obj: object, msg?: string): void;
|
||||
warn(obj: object, msg?: string): void;
|
||||
}
|
||||
|
||||
type Inspection = NpmGlobalPaseoInstall | Error;
|
||||
type RuntimeCall = "inspect" | "installLatest";
|
||||
|
||||
const globalRoot = "/global/lib";
|
||||
const globalNodeModules = `${globalRoot}/node_modules`;
|
||||
const cliPackagePath = `${globalNodeModules}/@getpaseo/cli`;
|
||||
const npmServerPackageRoot = `${cliPackagePath}/node_modules/@getpaseo/server`;
|
||||
const sourceServerPackageRoot = "/repo/packages/server";
|
||||
|
||||
function npmGlobalPaseoInstall(
|
||||
version: string,
|
||||
options?: { linked?: boolean },
|
||||
): NpmGlobalPaseoInstall {
|
||||
return {
|
||||
version,
|
||||
packagePath: cliPackagePath,
|
||||
globalRootPath: globalRoot,
|
||||
isLinked: options?.linked === true,
|
||||
};
|
||||
}
|
||||
|
||||
function createLogger(): TestLogger {
|
||||
return {
|
||||
errors: [],
|
||||
warnings: [],
|
||||
error(obj, msg) {
|
||||
this.errors.push({ obj, msg });
|
||||
},
|
||||
warn(obj, msg) {
|
||||
this.warnings.push({ obj, msg });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createRuntime(input: {
|
||||
inspections: Inspection[];
|
||||
currentServerPackageRoot?: string | null;
|
||||
installResult?: CommandResult;
|
||||
calls?: RuntimeCall[];
|
||||
}): DaemonSelfUpdateRuntime {
|
||||
const calls = input.calls ?? [];
|
||||
return {
|
||||
npm: {
|
||||
async inspect() {
|
||||
calls.push("inspect");
|
||||
const inspection = input.inspections.shift();
|
||||
if (!inspection) {
|
||||
throw new Error("Unexpected npm global install inspection");
|
||||
}
|
||||
if (inspection instanceof Error) {
|
||||
throw inspection;
|
||||
}
|
||||
return inspection;
|
||||
},
|
||||
async installLatest() {
|
||||
calls.push("installLatest");
|
||||
return input.installResult ?? { exitCode: 0, stdout: "changed 42 packages", stderr: "" };
|
||||
},
|
||||
},
|
||||
installOrigin: {
|
||||
resolveCurrentServerPackageRoot() {
|
||||
return input.currentServerPackageRoot ?? npmServerPackageRoot;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function runUpdate(input: {
|
||||
runtime: DaemonSelfUpdateRuntime;
|
||||
daemonVersion?: string | null;
|
||||
phases?: DaemonSelfUpdatePhase[];
|
||||
}) {
|
||||
const logger = createLogger();
|
||||
const updater = new DaemonSelfUpdater(input.runtime);
|
||||
const phases = input.phases ?? [];
|
||||
const result = await updater.update({
|
||||
daemonVersion: input.daemonVersion ?? "0.1.15",
|
||||
onProgress: (phase) => phases.push(phase),
|
||||
logger,
|
||||
});
|
||||
return { result, logger, phases };
|
||||
}
|
||||
|
||||
describe("DaemonSelfUpdater", () => {
|
||||
test("updates a daemon that is running from the npm global cli install", async () => {
|
||||
const calls: RuntimeCall[] = [];
|
||||
const runtime = createRuntime({
|
||||
calls,
|
||||
inspections: [npmGlobalPaseoInstall("0.1.15"), npmGlobalPaseoInstall("0.1.96")],
|
||||
});
|
||||
|
||||
const { result, phases } = await runUpdate({ runtime });
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
error: null,
|
||||
newVersion: "0.1.96",
|
||||
});
|
||||
expect(phases).toEqual(["starting", "downloading", "installing", "complete"]);
|
||||
expect(calls).toEqual(["inspect", "installLatest", "inspect"]);
|
||||
});
|
||||
|
||||
test("does not run install when npm global cli is missing", async () => {
|
||||
const calls: RuntimeCall[] = [];
|
||||
const runtime = createRuntime({
|
||||
calls,
|
||||
inspections: [new Error("@getpaseo/cli is not installed with npm -g on this host")],
|
||||
});
|
||||
|
||||
const { result, phases } = await runUpdate({ runtime });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("@getpaseo/cli is not installed with npm -g on this host");
|
||||
expect(phases).toEqual(["starting"]);
|
||||
expect(calls).toEqual(["inspect"]);
|
||||
});
|
||||
|
||||
test("does not update a daemon whose version does not match the npm global cli", async () => {
|
||||
const calls: RuntimeCall[] = [];
|
||||
const runtime = createRuntime({
|
||||
calls,
|
||||
inspections: [npmGlobalPaseoInstall("0.1.15")],
|
||||
});
|
||||
|
||||
const { result } = await runUpdate({ runtime, daemonVersion: "0.1.96" });
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error:
|
||||
"This daemon is not running from the npm global @getpaseo/cli install (global npm has 0.1.15, daemon is 0.1.96).",
|
||||
newVersion: null,
|
||||
});
|
||||
expect(calls).toEqual(["inspect"]);
|
||||
});
|
||||
|
||||
test("does not update a daemon running outside the npm global package tree", async () => {
|
||||
const calls: RuntimeCall[] = [];
|
||||
const runtime = createRuntime({
|
||||
calls,
|
||||
currentServerPackageRoot: sourceServerPackageRoot,
|
||||
inspections: [npmGlobalPaseoInstall("0.1.15")],
|
||||
});
|
||||
|
||||
const { result } = await runUpdate({ runtime });
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: "This daemon is not running from the npm global @getpaseo/cli install.",
|
||||
newVersion: null,
|
||||
});
|
||||
expect(calls).toEqual(["inspect"]);
|
||||
});
|
||||
|
||||
test("does not update linked global installs", async () => {
|
||||
const runtime = createRuntime({
|
||||
inspections: [npmGlobalPaseoInstall("0.1.15", { linked: true })],
|
||||
});
|
||||
|
||||
const { result } = await runUpdate({ runtime });
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error:
|
||||
"The global @getpaseo/cli install is linked; self-update only supports normal npm global installs.",
|
||||
newVersion: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects concurrent update requests", async () => {
|
||||
const calls: RuntimeCall[] = [];
|
||||
let resolveInstall: ((result: CommandResult) => void) | null = null;
|
||||
let installStartedResolve: (() => void) | null = null;
|
||||
const installStarted = new Promise<void>((resolve) => {
|
||||
installStartedResolve = resolve;
|
||||
});
|
||||
const runtime: DaemonSelfUpdateRuntime = {
|
||||
npm: {
|
||||
async inspect() {
|
||||
calls.push("inspect");
|
||||
return npmGlobalPaseoInstall("0.1.15");
|
||||
},
|
||||
async installLatest() {
|
||||
calls.push("installLatest");
|
||||
installStartedResolve?.();
|
||||
return new Promise<CommandResult>((resolve) => {
|
||||
resolveInstall = resolve;
|
||||
});
|
||||
},
|
||||
},
|
||||
installOrigin: {
|
||||
resolveCurrentServerPackageRoot() {
|
||||
return npmServerPackageRoot;
|
||||
},
|
||||
},
|
||||
};
|
||||
const logger = createLogger();
|
||||
const updater = new DaemonSelfUpdater(runtime);
|
||||
|
||||
const firstUpdate = updater.update({
|
||||
daemonVersion: "0.1.15",
|
||||
onProgress: () => {},
|
||||
logger,
|
||||
});
|
||||
await installStarted;
|
||||
|
||||
await expect(
|
||||
updater.update({
|
||||
daemonVersion: "0.1.15",
|
||||
onProgress: () => {},
|
||||
logger,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(DaemonSelfUpdateInProgressError);
|
||||
|
||||
resolveInstall?.({ exitCode: 0, stdout: "updated", stderr: "" });
|
||||
await expect(firstUpdate).resolves.toMatchObject({ success: true });
|
||||
expect(calls).toEqual(["inspect", "installLatest", "inspect"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { getErrorMessage } from "@getpaseo/protocol/error-utils";
|
||||
import {
|
||||
daemonInstallOriginRuntime,
|
||||
validateDaemonInstallOrigin,
|
||||
type DaemonInstallOriginRuntime,
|
||||
} from "./install-origin.js";
|
||||
import { npmGlobalPaseoCli, type NpmGlobalPaseoCli } from "./npm-global-cli.js";
|
||||
|
||||
export type DaemonSelfUpdatePhase = "starting" | "downloading" | "installing" | "complete";
|
||||
|
||||
export interface DaemonSelfUpdateResult {
|
||||
success: boolean;
|
||||
error: string | null;
|
||||
newVersion: string | null;
|
||||
}
|
||||
|
||||
export interface DaemonSelfUpdateInput {
|
||||
daemonVersion: string | null;
|
||||
onProgress: (phase: DaemonSelfUpdatePhase) => void;
|
||||
logger: DaemonSelfUpdateLogger;
|
||||
}
|
||||
|
||||
export interface DaemonSelfUpdateLogger {
|
||||
error(obj: object, msg?: string): void;
|
||||
warn(obj: object, msg?: string): void;
|
||||
}
|
||||
|
||||
export interface DaemonSelfUpdateRuntime {
|
||||
npm: NpmGlobalPaseoCli;
|
||||
installOrigin: DaemonInstallOriginRuntime;
|
||||
}
|
||||
|
||||
export class DaemonSelfUpdateInProgressError extends Error {
|
||||
constructor() {
|
||||
super("An update is already in progress");
|
||||
this.name = "DaemonSelfUpdateInProgressError";
|
||||
}
|
||||
}
|
||||
|
||||
const defaultRuntime: DaemonSelfUpdateRuntime = {
|
||||
npm: npmGlobalPaseoCli,
|
||||
installOrigin: daemonInstallOriginRuntime,
|
||||
};
|
||||
|
||||
export class DaemonSelfUpdater {
|
||||
private inProgress = false;
|
||||
|
||||
constructor(private readonly runtime: DaemonSelfUpdateRuntime = defaultRuntime) {}
|
||||
|
||||
async update(input: DaemonSelfUpdateInput): Promise<DaemonSelfUpdateResult> {
|
||||
if (this.inProgress) {
|
||||
throw new DaemonSelfUpdateInProgressError();
|
||||
}
|
||||
|
||||
this.inProgress = true;
|
||||
try {
|
||||
input.onProgress("starting");
|
||||
const install = await this.runtime.npm.inspect();
|
||||
const unsupportedReason = validateDaemonInstallOrigin(
|
||||
install,
|
||||
input.daemonVersion,
|
||||
this.runtime.installOrigin,
|
||||
);
|
||||
if (unsupportedReason) {
|
||||
return { success: false, error: unsupportedReason, newVersion: null };
|
||||
}
|
||||
|
||||
input.onProgress("downloading");
|
||||
input.onProgress("installing");
|
||||
|
||||
const result = await this.runtime.npm.installLatest();
|
||||
if (result.exitCode !== 0) {
|
||||
const error =
|
||||
result.stderr.trim() || result.stdout.trim() || `npm exited with code ${result.exitCode}`;
|
||||
input.logger.error(
|
||||
{ exitCode: result.exitCode, stderr: result.stderr },
|
||||
"Daemon self-update failed",
|
||||
);
|
||||
return { success: false, error, newVersion: null };
|
||||
}
|
||||
|
||||
const updatedInstall = await this.runtime.npm.inspect().catch((error: unknown) => {
|
||||
input.logger.warn({ err: error }, "Unable to read updated npm package version");
|
||||
return null;
|
||||
});
|
||||
|
||||
input.onProgress("complete");
|
||||
return { success: true, error: null, newVersion: updatedInstall?.version ?? null };
|
||||
} catch (error) {
|
||||
input.logger.error({ err: error }, "Daemon self-update failed with exception");
|
||||
return { success: false, error: getErrorMessage(error), newVersion: null };
|
||||
} finally {
|
||||
this.inProgress = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const daemonSelfUpdater = new DaemonSelfUpdater();
|
||||
@@ -42,10 +42,15 @@ function makeSubsystem(overrides: {
|
||||
getWebSocketRuntimeMetrics?: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null;
|
||||
}) {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const host: DaemonSessionHost = { emit: (msg) => emitted.push(msg) };
|
||||
const restartIntents: Parameters<DaemonSessionHost["emitLifecycleIntent"]>[0][] = [];
|
||||
const host: DaemonSessionHost = {
|
||||
emit: (msg) => emitted.push(msg),
|
||||
emitLifecycleIntent: (intent) => restartIntents.push(intent),
|
||||
};
|
||||
const paseoHome = makeHome();
|
||||
const subsystem = new DaemonSession({
|
||||
host,
|
||||
clientId: "client-1",
|
||||
paseoHome,
|
||||
serverId: overrides.serverId,
|
||||
daemonVersion: overrides.daemonVersion,
|
||||
@@ -57,7 +62,7 @@ function makeSubsystem(overrides: {
|
||||
getWebSocketRuntimeMetrics: overrides.getWebSocketRuntimeMetrics,
|
||||
logger: pino({ level: "silent" }),
|
||||
});
|
||||
return { subsystem, emitted, paseoHome };
|
||||
return { subsystem, emitted, paseoHome, restartIntents };
|
||||
}
|
||||
|
||||
describe("DaemonSession", () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
collectDaemonDiagnostics,
|
||||
type DaemonWebSocketRuntimeDiagnosticSnapshot,
|
||||
} from "./diagnostics.js";
|
||||
import { DaemonSelfUpdateSessionController } from "./daemon-self-update-session-controller.js";
|
||||
import type { ManagedAgent } from "../../agent/agent-manager.js";
|
||||
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "../../workspace-registry.js";
|
||||
|
||||
@@ -24,10 +25,17 @@ export interface DaemonRuntimeConfig {
|
||||
|
||||
export interface DaemonSessionHost {
|
||||
emit(msg: SessionOutboundMessage): void;
|
||||
emitLifecycleIntent(intent: {
|
||||
type: "restart";
|
||||
clientId: string;
|
||||
requestId: string;
|
||||
reason?: string;
|
||||
}): void;
|
||||
}
|
||||
|
||||
export interface DaemonSessionOptions {
|
||||
host: DaemonSessionHost;
|
||||
clientId: string;
|
||||
paseoHome: string;
|
||||
serverId: string | undefined;
|
||||
daemonVersion: string | undefined;
|
||||
@@ -49,6 +57,7 @@ export interface DaemonSessionOptions {
|
||||
*/
|
||||
export class DaemonSession {
|
||||
private readonly host: DaemonSessionHost;
|
||||
private readonly clientId: string;
|
||||
private readonly paseoHome: string;
|
||||
private readonly serverId: string | undefined;
|
||||
private readonly daemonVersion: string | undefined;
|
||||
@@ -59,9 +68,11 @@ export class DaemonSession {
|
||||
private readonly listProviderAvailability: () => Promise<ProviderAvailability[]>;
|
||||
private readonly getWebSocketRuntimeMetrics: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null;
|
||||
private readonly logger: pino.Logger;
|
||||
private readonly selfUpdate: DaemonSelfUpdateSessionController;
|
||||
|
||||
constructor(options: DaemonSessionOptions) {
|
||||
this.host = options.host;
|
||||
this.clientId = options.clientId;
|
||||
this.paseoHome = options.paseoHome;
|
||||
this.serverId = options.serverId;
|
||||
this.daemonVersion = options.daemonVersion;
|
||||
@@ -72,6 +83,13 @@ export class DaemonSession {
|
||||
this.listProviderAvailability = options.listProviderAvailability;
|
||||
this.getWebSocketRuntimeMetrics = options.getWebSocketRuntimeMetrics ?? (() => null);
|
||||
this.logger = options.logger;
|
||||
this.selfUpdate = new DaemonSelfUpdateSessionController({
|
||||
clientId: this.clientId,
|
||||
daemonVersion: this.daemonVersion ?? null,
|
||||
emit: (msg) => this.host.emit(msg),
|
||||
emitLifecycleIntent: (intent) => this.host.emitLifecycleIntent(intent),
|
||||
sessionLogger: this.logger,
|
||||
});
|
||||
}
|
||||
|
||||
async handleGetStatusRequest(
|
||||
@@ -191,4 +209,10 @@ export class DaemonSession {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async handleUpdateRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "daemon.update.request" }>,
|
||||
): Promise<void> {
|
||||
await this.selfUpdate.dispatch(msg);
|
||||
}
|
||||
}
|
||||
|
||||
89
packages/server/src/server/session/daemon/install-origin.ts
Normal file
89
packages/server/src/server/session/daemon/install-origin.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { z } from "zod";
|
||||
import { isRealpathInsideRoot } from "../../../utils/path.js";
|
||||
import { PASEO_CLI_PACKAGE, type NpmGlobalPaseoInstall } from "./npm-global-cli.js";
|
||||
|
||||
const PackageJsonSchema = z.object({ name: z.string().optional() }).passthrough();
|
||||
|
||||
export interface DaemonInstallOriginRuntime {
|
||||
resolveCurrentServerPackageRoot(): string | null;
|
||||
}
|
||||
|
||||
export const daemonInstallOriginRuntime: DaemonInstallOriginRuntime = {
|
||||
resolveCurrentServerPackageRoot,
|
||||
};
|
||||
|
||||
export function validateDaemonInstallOrigin(
|
||||
install: NpmGlobalPaseoInstall,
|
||||
daemonVersion: string | null,
|
||||
runtime: DaemonInstallOriginRuntime = daemonInstallOriginRuntime,
|
||||
): string | null {
|
||||
if (install.isLinked) {
|
||||
return `The global ${PASEO_CLI_PACKAGE} install is linked; self-update only supports normal npm global installs.`;
|
||||
}
|
||||
|
||||
if (daemonVersion && install.version !== daemonVersion) {
|
||||
return `This daemon is not running from the npm global ${PASEO_CLI_PACKAGE} install (global npm has ${install.version}, daemon is ${daemonVersion}).`;
|
||||
}
|
||||
|
||||
const currentServerPackageRoot = runtime.resolveCurrentServerPackageRoot();
|
||||
if (!currentServerPackageRoot) {
|
||||
return "Unable to verify that this daemon is running from an npm global install.";
|
||||
}
|
||||
|
||||
if (!isCurrentServerUnderNpmInstall(currentServerPackageRoot, install)) {
|
||||
return `This daemon is not running from the npm global ${PASEO_CLI_PACKAGE} install.`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isCurrentServerUnderNpmInstall(
|
||||
currentServerPackageRoot: string,
|
||||
install: NpmGlobalPaseoInstall,
|
||||
): boolean {
|
||||
const roots = install.globalRootPath
|
||||
? [install.packagePath, globalNodeModulesPath(install.globalRootPath)]
|
||||
: [install.packagePath];
|
||||
|
||||
return roots.some((root) => isRealpathInsideRoot(root, currentServerPackageRoot));
|
||||
}
|
||||
|
||||
function globalNodeModulesPath(globalRootPath: string): string {
|
||||
const normalized = path.normalize(globalRootPath);
|
||||
return path.basename(normalized) === "node_modules"
|
||||
? normalized
|
||||
: path.join(normalized, "node_modules");
|
||||
}
|
||||
|
||||
function resolveCurrentServerPackageRoot(): string | null {
|
||||
return resolvePackageRootFrom(fileURLToPath(import.meta.url), "@getpaseo/server");
|
||||
}
|
||||
|
||||
function resolvePackageRootFrom(startPath: string, packageName: string): string | null {
|
||||
let currentDir = path.dirname(startPath);
|
||||
|
||||
while (true) {
|
||||
const packageJsonPath = path.join(currentDir, "package.json");
|
||||
if (existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const packageJson = PackageJsonSchema.parse(
|
||||
JSON.parse(readFileSync(packageJsonPath, "utf8")),
|
||||
);
|
||||
if (packageJson.name === packageName) {
|
||||
return currentDir;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const parentDir = path.dirname(currentDir);
|
||||
if (parentDir === currentDir) {
|
||||
return null;
|
||||
}
|
||||
currentDir = parentDir;
|
||||
}
|
||||
}
|
||||
107
packages/server/src/server/session/daemon/npm-global-cli.test.ts
Normal file
107
packages/server/src/server/session/daemon/npm-global-cli.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import path from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { DefaultNpmGlobalPaseoCli } from "./npm-global-cli.js";
|
||||
|
||||
interface CommandCall {
|
||||
command: string;
|
||||
args: string[];
|
||||
timeout?: number;
|
||||
maxBuffer?: number;
|
||||
}
|
||||
|
||||
const globalRoot = path.join(path.sep, "global", "lib");
|
||||
const globalNodeModules = path.join(globalRoot, "node_modules");
|
||||
const cliPackagePath = path.join(globalNodeModules, "@getpaseo", "cli");
|
||||
|
||||
function npmGlobalPaseoCliJson(version: string, options?: { linked?: boolean }): string {
|
||||
return JSON.stringify({
|
||||
name: "lib",
|
||||
path: globalRoot,
|
||||
dependencies: {
|
||||
"@getpaseo/cli": {
|
||||
version,
|
||||
path: cliPackagePath,
|
||||
link: options?.linked === true,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("DefaultNpmGlobalPaseoCli", () => {
|
||||
test("inspects the npm global cli install with npm -g ls", async () => {
|
||||
const calls: CommandCall[] = [];
|
||||
const cli = new DefaultNpmGlobalPaseoCli(async (command, args, options) => {
|
||||
calls.push({
|
||||
command,
|
||||
args,
|
||||
timeout: options?.timeout,
|
||||
maxBuffer: options?.maxBuffer,
|
||||
});
|
||||
return { exitCode: 0, stdout: npmGlobalPaseoCliJson("0.1.15"), stderr: "" };
|
||||
});
|
||||
|
||||
await expect(cli.inspect()).resolves.toEqual({
|
||||
version: "0.1.15",
|
||||
packagePath: cliPackagePath,
|
||||
globalRootPath: globalRoot,
|
||||
isLinked: false,
|
||||
});
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
command: "npm",
|
||||
args: ["-g", "ls", "@getpaseo/cli", "--json", "--depth=0", "--long"],
|
||||
timeout: 10_000,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("runs the global install command for the latest cli", async () => {
|
||||
const calls: CommandCall[] = [];
|
||||
const cli = new DefaultNpmGlobalPaseoCli(async (command, args, options) => {
|
||||
calls.push({
|
||||
command,
|
||||
args,
|
||||
timeout: options?.timeout,
|
||||
maxBuffer: options?.maxBuffer,
|
||||
});
|
||||
return { exitCode: 0, stdout: "changed 42 packages", stderr: "" };
|
||||
});
|
||||
|
||||
await expect(cli.installLatest()).resolves.toEqual({
|
||||
exitCode: 0,
|
||||
stdout: "changed 42 packages",
|
||||
stderr: "",
|
||||
});
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
command: "npm",
|
||||
args: ["install", "-g", "@getpaseo/cli@latest"],
|
||||
timeout: 300_000,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("reports missing npm when npm exits without JSON", async () => {
|
||||
const cli = new DefaultNpmGlobalPaseoCli(async () => ({
|
||||
exitCode: 127,
|
||||
stdout: "",
|
||||
stderr: "npm: command not found",
|
||||
}));
|
||||
|
||||
await expect(cli.inspect()).rejects.toThrow("npm: command not found");
|
||||
});
|
||||
|
||||
test("reports missing global cli when npm output has no cli dependency", async () => {
|
||||
const cli = new DefaultNpmGlobalPaseoCli(async () => ({
|
||||
exitCode: 1,
|
||||
stdout: JSON.stringify({ name: "lib", path: globalRoot, dependencies: {} }),
|
||||
stderr: "missing",
|
||||
}));
|
||||
|
||||
await expect(cli.inspect()).rejects.toThrow(
|
||||
"@getpaseo/cli is not installed with npm -g on this host",
|
||||
);
|
||||
});
|
||||
});
|
||||
147
packages/server/src/server/session/daemon/npm-global-cli.ts
Normal file
147
packages/server/src/server/session/daemon/npm-global-cli.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { getErrorMessage } from "@getpaseo/protocol/error-utils";
|
||||
import { z } from "zod";
|
||||
import { execCommand } from "../../../utils/spawn.js";
|
||||
|
||||
export const PASEO_CLI_PACKAGE = "@getpaseo/cli";
|
||||
|
||||
const NPM_PROBE_TIMEOUT_MS = 10_000;
|
||||
const NPM_INSTALL_TIMEOUT_MS = 300_000;
|
||||
const NPM_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
const NpmGlobalListSchema = z
|
||||
.object({
|
||||
path: z.string().optional(),
|
||||
dependencies: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const NpmGlobalCliPackageSchema = z
|
||||
.object({
|
||||
version: z.string(),
|
||||
path: z.string(),
|
||||
link: z.boolean().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const CommandErrorSchema = z
|
||||
.object({
|
||||
code: z.union([z.number(), z.string()]).optional(),
|
||||
stdout: z.string().optional(),
|
||||
stderr: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export interface CommandOptions {
|
||||
timeout?: number;
|
||||
maxBuffer?: number;
|
||||
}
|
||||
|
||||
export interface CommandResult {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export interface NpmGlobalPaseoInstall {
|
||||
version: string;
|
||||
packagePath: string;
|
||||
globalRootPath: string | null;
|
||||
isLinked: boolean;
|
||||
}
|
||||
|
||||
export interface NpmGlobalPaseoCli {
|
||||
inspect(): Promise<NpmGlobalPaseoInstall>;
|
||||
installLatest(): Promise<CommandResult>;
|
||||
}
|
||||
|
||||
export type CommandRunner = (
|
||||
command: string,
|
||||
args: string[],
|
||||
options?: CommandOptions,
|
||||
) => Promise<CommandResult>;
|
||||
|
||||
async function runExternalCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
options?: CommandOptions,
|
||||
): Promise<CommandResult> {
|
||||
try {
|
||||
const { stdout, stderr } = await execCommand(command, args, {
|
||||
timeout: options?.timeout,
|
||||
maxBuffer: options?.maxBuffer,
|
||||
});
|
||||
return { exitCode: 0, stdout, stderr };
|
||||
} catch (error) {
|
||||
const parsed = CommandErrorSchema.safeParse(error);
|
||||
if (!parsed.success) {
|
||||
return { exitCode: 1, stdout: "", stderr: getErrorMessage(error) };
|
||||
}
|
||||
|
||||
return {
|
||||
exitCode: typeof parsed.data.code === "number" ? parsed.data.code : 1,
|
||||
stdout: parsed.data.stdout ?? "",
|
||||
stderr: parsed.data.stderr || getErrorMessage(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function parseNpmGlobalPaseoInstall(stdout: string): NpmGlobalPaseoInstall | null {
|
||||
let parsedJson: unknown;
|
||||
try {
|
||||
parsedJson = JSON.parse(stdout);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const list = NpmGlobalListSchema.safeParse(parsedJson);
|
||||
if (!list.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawCliPackage = list.data.dependencies?.[PASEO_CLI_PACKAGE];
|
||||
const cliPackage = NpmGlobalCliPackageSchema.safeParse(rawCliPackage);
|
||||
if (!cliPackage.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
version: cliPackage.data.version,
|
||||
packagePath: cliPackage.data.path,
|
||||
globalRootPath: list.data.path ?? null,
|
||||
isLinked: cliPackage.data.link === true,
|
||||
};
|
||||
}
|
||||
|
||||
export class DefaultNpmGlobalPaseoCli implements NpmGlobalPaseoCli {
|
||||
constructor(private readonly runCommand: CommandRunner = runExternalCommand) {}
|
||||
|
||||
async inspect(): Promise<NpmGlobalPaseoInstall> {
|
||||
const result = await this.runCommand(
|
||||
"npm",
|
||||
["-g", "ls", PASEO_CLI_PACKAGE, "--json", "--depth=0", "--long"],
|
||||
{
|
||||
timeout: NPM_PROBE_TIMEOUT_MS,
|
||||
maxBuffer: NPM_MAX_BUFFER_BYTES,
|
||||
},
|
||||
);
|
||||
|
||||
if (result.exitCode !== 0 && result.stdout.trim().length === 0) {
|
||||
throw new Error(result.stderr.trim() || "npm is not available on this host");
|
||||
}
|
||||
|
||||
const install = parseNpmGlobalPaseoInstall(result.stdout);
|
||||
if (!install) {
|
||||
throw new Error(`${PASEO_CLI_PACKAGE} is not installed with npm -g on this host`);
|
||||
}
|
||||
return install;
|
||||
}
|
||||
|
||||
installLatest(): Promise<CommandResult> {
|
||||
return this.runCommand("npm", ["install", "-g", `${PASEO_CLI_PACKAGE}@latest`], {
|
||||
timeout: NPM_INSTALL_TIMEOUT_MS,
|
||||
maxBuffer: NPM_MAX_BUFFER_BYTES,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const npmGlobalPaseoCli = new DefaultNpmGlobalPaseoCli();
|
||||
@@ -1183,6 +1183,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
agentDetach: true,
|
||||
// COMPAT(daemonDiagnostics): added in v0.1.100, remove gate after 2026-12-25 once daemon floor >= v0.1.100.
|
||||
daemonDiagnostics: true,
|
||||
// COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13.
|
||||
daemonSelfUpdate: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Dirent } from "node:fs";
|
||||
import { readdir, realpath, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { isPathInsideRoot } from "./path.js";
|
||||
|
||||
export interface SearchHomeDirectoriesOptions {
|
||||
homeDir: string;
|
||||
@@ -786,11 +787,6 @@ function normalizeRelativePath(homeRoot: string, absolutePath: string): string {
|
||||
return relative.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function isPathInsideRoot(root: string, target: string): boolean {
|
||||
const relative = path.relative(root, target);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function normalizeQueryParts(query: string, homeRoot: string): QueryParts | null {
|
||||
const typedQuery = query.trim().replace(/\\/g, "/");
|
||||
let normalized = typedQuery;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { areEquivalentPaths, createPathEquivalenceMatcher } from "./path.js";
|
||||
import { areEquivalentPaths, createPathEquivalenceMatcher, isPathInsideRoot } from "./path.js";
|
||||
|
||||
describe("path equivalence", () => {
|
||||
test.each([
|
||||
@@ -19,4 +19,18 @@ describe("path equivalence", () => {
|
||||
areEquivalentPaths("/Users/Administrator/GhostFactory", "/users/administrator/ghostfactory"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("checks POSIX root containment without prefix false positives", () => {
|
||||
expect(isPathInsideRoot("/opt/paseo", "/opt/paseo/node_modules/@getpaseo/server")).toBe(true);
|
||||
expect(isPathInsideRoot("/opt/paseo", "/opt/paseo-other")).toBe(false);
|
||||
});
|
||||
|
||||
test("checks Windows root containment case-insensitively", () => {
|
||||
expect(
|
||||
isPathInsideRoot("C:\\Paseo\\node_modules", "c:/paseo/node_modules/@getpaseo/server"),
|
||||
).toBe(true);
|
||||
expect(isPathInsideRoot("C:\\Paseo\\node_modules", "C:\\Paseo\\node_modules-other")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,6 +58,25 @@ export function createRealpathAwarePathMatcher(target: string): (candidate: stri
|
||||
};
|
||||
}
|
||||
|
||||
export function isPathInsideRoot(root: string, candidate: string): boolean {
|
||||
const compareAsWindows = shouldCompareAsWindows(root, candidate);
|
||||
const platformPath = compareAsWindows ? nodePath.win32 : nodePath.posix;
|
||||
const normalizedRoot = normalizePathForComparison(root, compareAsWindows);
|
||||
const normalizedCandidate = normalizePathForComparison(candidate, compareAsWindows);
|
||||
const relative = platformPath.relative(normalizedRoot, normalizedCandidate);
|
||||
|
||||
return relative === "" || (!relative.startsWith("..") && !platformPath.isAbsolute(relative));
|
||||
}
|
||||
|
||||
export function isRealpathInsideRoot(root: string, candidate: string): boolean {
|
||||
const rootVariants = collectPathVariants(root);
|
||||
const candidateVariants = collectPathVariants(candidate);
|
||||
|
||||
return rootVariants.some((rootVariant) =>
|
||||
candidateVariants.some((candidateVariant) => isPathInsideRoot(rootVariant, candidateVariant)),
|
||||
);
|
||||
}
|
||||
|
||||
function collectPathVariants(value: string): string[] {
|
||||
const variants = new Set<string>([value]);
|
||||
for (const realpath of resolveRealpathVariants(value)) {
|
||||
|
||||
Reference in New Issue
Block a user