feat: implement two-shim CLI install with fallback instructions

This commit is contained in:
Mohamed Boudra
2026-03-08 15:40:02 +07:00
parent 2d5d0dcacd
commit 2a1ef17107
15 changed files with 1176 additions and 244 deletions
@@ -23,6 +23,7 @@ import {
type ManagedDaemonLogs,
type ManagedPairingOffer,
type ManagedDaemonStatus,
type CliManualInstructions,
} from "@/desktop/managed-runtime/managed-runtime";
export interface LocalDaemonSectionProps {
@@ -37,12 +38,17 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
const [isInstallingCli, setIsInstallingCli] = useState(false);
const [isSavingTcpSettings, setIsSavingTcpSettings] = useState(false);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [cliStatusMessage, setCliStatusMessage] = useState<string | null>(null);
const [managedLogs, setManagedLogs] = useState<ManagedDaemonLogs | null>(null);
const [isTcpModalOpen, setIsTcpModalOpen] = useState(false);
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
const [isPairingModalOpen, setIsPairingModalOpen] = useState(false);
const [isCliInstallModalOpen, setIsCliInstallModalOpen] = useState(false);
const [isLoadingPairing, setIsLoadingPairing] = useState(false);
const [pairingOffer, setPairingOffer] = useState<ManagedPairingOffer | null>(null);
const [cliInstallInstructions, setCliInstallInstructions] = useState<CliManualInstructions | null>(
null
);
const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null);
const [tcpHostInput, setTcpHostInput] = useState(DEFAULT_TCP_HOST);
const [tcpPortInput, setTcpPortInput] = useState(String(DEFAULT_TCP_PORT));
@@ -135,22 +141,48 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
return;
}
setIsInstallingCli(true);
setStatusMessage(null);
const isInstalling = !managedStatus?.cliShimPath;
setCliStatusMessage(
isInstalling
? "A permissions popup may appear while Paseo installs the CLI globally."
: null
);
const action = managedStatus?.cliShimPath ? uninstallManagedCliShim : installManagedCliShim;
void action()
.then((result) => {
setStatusMessage(result.message);
setCliStatusMessage(result.message);
if (result.manualInstructions) {
setCliInstallInstructions(result.manualInstructions);
setIsCliInstallModalOpen(true);
} else {
setCliInstallInstructions(null);
setIsCliInstallModalOpen(false);
}
return loadManagedStatus();
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
setStatusMessage(`CLI shim action failed: ${message}`);
setCliStatusMessage(`CLI shim action failed: ${message}`);
})
.finally(() => {
setIsInstallingCli(false);
});
}, [isInstallingCli, loadManagedStatus, managedStatus?.cliShimPath, showSection]);
const handleCopyCliInstallCommands = useCallback(() => {
if (!cliInstallInstructions?.commands) {
return;
}
void Clipboard.setStringAsync(cliInstallInstructions.commands)
.then(() => {
Alert.alert("Copied", "CLI install commands copied.");
})
.catch((error) => {
console.error("[Settings] Failed to copy CLI install commands", error);
Alert.alert("Error", "Unable to copy CLI install commands.");
});
}, [cliInstallInstructions?.commands]);
const handleCopyLogPath = useCallback(() => {
const logPath = managedLogs?.logPath ?? managedStatus?.logPath;
if (!logPath) {
@@ -340,8 +372,9 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>CLI shim</Text>
<Text style={styles.hintText}>
Installs `paseo` into your user path and points it at the managed daemon by default.
Installs a global `paseo` launcher that forwards into the desktop-managed runtime.
</Text>
{cliStatusMessage ? <Text style={styles.statusText}>{cliStatusMessage}</Text> : null}
</View>
<Button
variant="secondary"
@@ -475,6 +508,34 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
</View>
</AdaptiveModalSheet>
<AdaptiveModalSheet
visible={isCliInstallModalOpen}
onClose={() => setIsCliInstallModalOpen(false)}
title="Install CLI manually"
testID="managed-daemon-cli-install-dialog"
>
<View style={styles.modalBody}>
<Text style={styles.hintText}>
A permissions popup should appear when Paseo installs the CLI globally. If it does not
complete, open a terminal and run the commands below.
</Text>
{cliInstallInstructions?.detail ? (
<Text style={styles.hintText}>{cliInstallInstructions.detail}</Text>
) : null}
<Text style={styles.codeBlock} selectable>
{cliInstallInstructions?.commands ?? ""}
</Text>
<View style={styles.modalActions}>
<Button variant="secondary" size="sm" onPress={() => setIsCliInstallModalOpen(false)}>
Close
</Button>
<Button size="sm" onPress={handleCopyCliInstallCommands}>
Copy commands
</Button>
</View>
</View>
</AdaptiveModalSheet>
<AdaptiveModalSheet
visible={isPairingModalOpen}
onClose={() => setIsPairingModalOpen(false)}
@@ -736,6 +797,17 @@ const styles = StyleSheet.create((theme) => ({
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
lineHeight: 18,
},
codeBlock: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
lineHeight: 18,
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface0,
padding: theme.spacing[3],
},
input: {
borderWidth: 1,
borderColor: theme.colors.border,
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { parseCliShimResult } from "./managed-runtime";
describe("parseCliShimResult", () => {
it("parses manual install payloads from the desktop backend", () => {
expect(
parseCliShimResult({
status: "manualInstallRequired",
installed: false,
path: "/usr/local/bin/paseo",
message: "Install it manually.",
manualInstructions: {
title: "Install from Terminal",
detail: "Run these commands.",
commands: "sudo tee /usr/local/bin/paseo",
},
})
).toEqual({
status: "manualInstallRequired",
installed: false,
path: "/usr/local/bin/paseo",
message: "Install it manually.",
manualInstructions: {
title: "Install from Terminal",
detail: "Run these commands.",
commands: "sudo tee /usr/local/bin/paseo",
},
});
});
it("falls back to installed or removed when older payloads omit status", () => {
expect(
parseCliShimResult({
installed: true,
path: "/usr/local/bin/paseo",
message: "Installed.",
})
).toEqual({
status: "installed",
installed: true,
path: "/usr/local/bin/paseo",
message: "Installed.",
manualInstructions: null,
});
});
});
@@ -45,9 +45,22 @@ export type ManagedPairingOffer = {
};
export type CliShimResult = {
status:
| "installed"
| "removed"
| "elevationDenied"
| "automaticInstallUnavailable"
| "manualInstallRequired";
installed: boolean;
path: string | null;
message: string;
manualInstructions: CliManualInstructions | null;
};
export type CliManualInstructions = {
title: string;
detail: string;
commands: string;
};
export type ManagedTcpSettings = {
@@ -146,14 +159,29 @@ function parseManagedPairingOffer(raw: unknown): ManagedPairingOffer {
};
}
function parseCliShimResult(raw: unknown): CliShimResult {
function parseCliManualInstructions(raw: unknown): CliManualInstructions | null {
if (!isRecord(raw)) {
return null;
}
return {
title: toStringOrNull(raw.title) ?? "",
detail: toStringOrNull(raw.detail) ?? "",
commands: toStringOrNull(raw.commands) ?? "",
};
}
export function parseCliShimResult(raw: unknown): CliShimResult {
if (!isRecord(raw)) {
throw new Error("Unexpected CLI shim response.");
}
return {
status:
(toStringOrNull(raw.status) as CliShimResult["status"] | null) ??
(raw.installed === true ? "installed" : "removed"),
installed: raw.installed === true,
path: toStringOrNull(raw.path),
message: toStringOrNull(raw.message) ?? "",
manualInstructions: parseCliManualInstructions(raw.manualInstructions),
};
}