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
+233
View File
@@ -0,0 +1,233 @@
# Managed CLI Global Install Loop Plan
## Task
Implement a proper desktop-managed CLI install flow for the desktop app.
The current implementation installs a user-local shim and routes through the desktop app binary with `--paseo-cli-shim <state-file>`. The target design is:
- The installed PATH shim must be trivial and stable, like Tailscale.
- The installed PATH shim should forward argv to an inner executable/shim inside the app bundle.
- The inner executable/shim should resolve the bundled Node runtime and bundled `@getpaseo/cli` entrypoint and invoke the CLI directly.
- The CLI should gain Docker-like default connection resolution so the shim does not need to hardcode daemon transport details.
- The desktop install button should attempt a best-effort global install and clearly communicate that a permissions prompt may appear.
- If global install does not succeed, the app must present a fallback dialog with exact platform-specific terminal instructions.
This work must be planned and implemented cleanly, not patched onto the current state-file trampoline design.
## Relevant Code And Current Behavior
- `packages/desktop/src-tauri/src/runtime_manager.rs`
- Contains managed runtime install logic, CLI shim install/uninstall logic, and the current `try_run_cli_shim_from_args()` trampoline path.
- Today the installed shim calls the desktop app executable, passes `--paseo-cli-shim <state-file>`, and the app then resolves bundled Node + bundled CLI and execs them.
- Today macOS/Linux install to `~/.local/bin/paseo`; Windows installs a `.cmd` shim under the user data dir.
- `packages/desktop/src-tauri/src/lib.rs`
- Exposes `install_cli_shim` / `uninstall_cli_shim` Tauri commands and includes some desktop-side `command -v paseo` checks.
- `packages/app/src/desktop/managed-runtime/managed-runtime.ts`
- TypeScript bridge for desktop managed-runtime commands.
- Today `CliShimResult` is only `{ installed, path, message }`.
- `packages/app/src/desktop/components/desktop-updates-section.tsx`
- Contains the Install CLI / Uninstall CLI button and current status-message handling.
- Today there is no preflight permission notice and no fallback modal with manual commands.
- `packages/cli`
- The CLI currently expects explicit host wiring in some flows. It needs to be inspected and updated so that when no host is passed it behaves more like Docker:
1. use explicit `--host` if provided
2. otherwise try local socket / local pipe first
3. then fall back to `localhost:6767`
## Implementation Requirements
### 1. Replace the current installed shim architecture with a two-shim design
Implement two distinct layers:
- Outer PATH shim:
- Must be extremely simple and stable.
- It should do nothing except invoke an inner executable or shim within the installed app/bundle and forward argv.
- It must not embed managed runtime state, a state-file path, runtime id, socket path, or transport details.
- On macOS, the desired style is comparable to:
`#!/bin/sh`
`exec /Applications/Paseo.app/Contents/MacOS/paseo "$@"`
- Inner bundle shim / entrypoint:
- Lives inside the app bundle / managed runtime and is responsible for finding bundled Node and the bundled CLI entrypoint.
- It should invoke the bundled CLI directly, as if launching the packaged CLI with bundled Node.
- It must not route back through the desktop app command trampoline with `--paseo-cli-shim`.
Remove or obsolete the current state-file-based trampoline path if the new direct inner-shim path fully replaces it.
### 2. Make CLI host resolution work by default without shim transport knowledge
Update the CLI so that the launcher does not have to inject managed socket details in the normal case.
Desired behavior when the user runs `paseo` and no host is explicitly passed:
1. If `--host` is passed, use it.
2. Otherwise, try the platform-appropriate local managed/default transport first:
- Unix socket on macOS/Linux
- Named pipe on Windows, if supported by the CLI host format
3. If no local transport is available, fall back to `localhost:6767`.
The judge must verify the actual host-resolution code path and ensure it is not just documentation text.
### 3. Add best-effort global install behavior for the desktop-managed CLI
The desktop Install CLI action should attempt to install the outer shim globally in a platform-appropriate location.
Platform expectations:
- macOS:
- Prefer `/usr/local/bin/paseo`.
- Attempt a privileged install so the user sees the normal macOS admin-password prompt when needed.
- The flow should be initiated from the click action.
- Linux:
- Best effort only.
- If a reliable privileged install path is not available, fall back to manual instructions.
- Do not fake success.
- Windows:
- Best effort only.
- If protected install cannot be completed automatically, return a manual-instructions path.
Do not silently keep the old user-local install path as the primary desktop behavior on macOS.
### 4. Add explicit user-facing permission guidance before and after the attempt
When the user clicks Install CLI in the desktop UI:
- Immediately show a clear status line or notice saying that a permissions popup may appear to install the CLI globally.
- Do this before awaiting the install attempt, so the user understands what is happening.
If installation fails, or if the backend reports that manual action is required:
- Open a dedicated dialog/modal in the desktop UI.
- The modal should clearly explain:
- A permissions popup should appear prompting for permission to install the CLI globally.
- If it does not work, open a terminal and run the provided commands.
- Include a selectable and copyable code block with exact platform-specific commands.
### 5. Return structured install results from the desktop backend
Expand the desktop backend/frontend result shape for CLI install so the UI can make decisions without parsing freeform error strings.
The result must be rich enough to distinguish at least:
- install succeeded automatically
- install failed because elevation was denied
- install could not be attempted automatically on this platform
- manual installation is required
It must also include:
- installed path, when available
- a user-facing summary message
- manual instructions payload, when required
### 6. Manual fallback instructions must be real and platform-specific
The fallback dialog must show platform-specific commands that match the actual chosen launcher architecture.
That means the commands should install or write the simple outer shim that points at the inner app/bundle shim.
The commands must use real paths derived from the installed app/runtime layout, not placeholder pseudo-paths.
They must be quoted correctly for spaces in file paths.
### 7. Preserve or update uninstall behavior coherently
Uninstall CLI must continue to remove the installed outer shim from whatever location the new install flow uses.
If install location or result state changes, update the stored metadata and uninstall behavior accordingly.
### 8. Update or add verification coverage where practical
Add or update tests for the new behavior where practical and already patterned in the codebase.
At minimum, verify:
- any changed TypeScript types and desktop command parsing
- any changed Rust result serialization assumptions
- any changed CLI host-resolution logic
If automated coverage for the privileged install is impractical, document the manual verification path in code comments or test notes near the relevant smoke/integration checks rather than hand-waving it away.
## Constraints
- Do not reintroduce or preserve the current state-file-based app trampoline design unless strictly required for a narrow compatibility bridge.
- Do not parse user-facing error strings in the frontend to determine behavior.
- Do not implement the UI fallback as a generic alert with truncated instructions; use a proper modal/dialog with copyable command text.
- Do not leave macOS as a `~/.local/bin` install target for the primary desktop-managed global install flow.
- Do not skip typecheck. The repo instructions require typecheck after every change.
- Be careful with existing unrelated worktree changes; do not revert unrelated user changes.
## Acceptance Criteria
The judge should mark the loop successful only if all of the following are true:
1. The installed outer shim architecture has been simplified so it is a trivial forwarder to an inner app/bundle shim or executable, with no embedded managed state-file path.
2. The inner app/bundle shim directly invokes bundled Node + bundled CLI, rather than re-entering the desktop app via the current `--paseo-cli-shim` trampoline.
3. CLI default host resolution has been updated in code so that no-host invocation first prefers local transport and then falls back to `localhost:6767`.
4. The desktop Install CLI UI shows an up-front notice that a permissions popup may appear.
5. The desktop install flow returns structured results that distinguish automatic success from manual fallback cases.
6. On failure or manual-required paths, the desktop UI opens a dedicated dialog/modal with copyable platform-specific terminal instructions.
7. macOS best-effort install targets `/usr/local/bin/paseo` and is implemented as a privileged install attempt rather than the old user-local path.
8. Uninstall behavior remains coherent with the new install location and metadata.
9. `npm run typecheck` passes.
10. Any relevant tests added or changed by the implementation pass, or the worker explicitly documents why a test path is not runnable and what was verified instead.
## Hard Acceptance Criteria
The following are non-negotiable. If any one of them is not true in the code, the judge must return `criteria_met: false`.
### H1. Stable outer shim across runtime updates
The installed outer PATH shim must not hardcode a versioned managed-runtime path.
This specifically means:
- The installed outer shim at `/usr/local/bin/paseo` on macOS/Linux, or the Windows outer shim path, must not point directly into `paths.runtime_root` or any runtime-id/version-specific directory.
- Updating the managed runtime to a new runtime id/version must not require reinstalling the outer PATH shim just to keep `paseo` working.
- The outer shim may point at a stable app-bundle executable path or another stable non-versioned path under app-owned state, but not a versioned runtime root.
The judge must inspect the actual generated launcher code paths and fail if the installed shim still embeds a runtime-root-specific target.
### H2. CLI default host resolution must preserve configured non-default TCP daemons
The new CLI default-resolution logic must not discard a configured non-IPC TCP daemon endpoint.
Required order when `--host` is not passed:
1. If an explicit host override is present, use it.
2. Otherwise, if a local IPC target is discoverable, prefer it first.
3. Otherwise, if config or environment resolves to a TCP listen target, use that configured TCP target.
4. Only if none of the above is available, fall back to `localhost:6767`.
This means a user who has intentionally configured the daemon on a non-default TCP endpoint must still have the CLI connect there by default.
The judge must fail if the implementation:
- always falls back to `localhost:6767` after checking only IPC candidates, or
- ignores configured TCP listen values when no IPC target exists.
### H3. Tests must prove both hard cases
Automated verification must cover both failure modes found in review:
- a test or equivalent deterministic proof that the outer shim target remains stable across runtime updates / runtime-id changes, or at minimum that it resolves through a stable path that does not encode the runtime root
- a CLI host-resolution test proving that when no IPC target exists but config/environment specifies a non-default TCP daemon endpoint, that endpoint is chosen before `localhost:6767`
The judge must not accept hand-wavy reasoning in place of these checks.
## Execution Notes For Worker
- Start by tracing the current shim architecture and the CLI host-resolution path before editing.
- Prefer cohesive refactoring over additive patch layers.
- Keep the outer shim intentionally dumb.
- If you need a temporary compatibility bridge, keep it narrow and justify it in code comments or commit notes.
- Run `npm run typecheck` after completing the changes.
## Execution Notes For Judge
- Verify the actual code paths, not just strings or comments.
- Inspect whether the old `--paseo-cli-shim <state-file>` trampoline still remains as the active path. If so, fail unless it is clearly obsolete and no longer used by the installed outer shim flow.
- Verify that the fallback dialog is a real modal/dialog in the UI code and that it contains actionable copyable instructions.
- Verify the install target logic on macOS is `/usr/local/bin/paseo`.
- Verify `npm run typecheck` was run successfully from logs or explicit output artifacts.
@@ -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),
};
}
+9 -9
View File
@@ -66,7 +66,7 @@ export function createCli(): Command {
.option('--label <key=value>', 'Filter by label (can be used multiple times)', collectMultiple, [])
.option('--thinking <id>', 'Filter by thinking option ID')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runLsCommand))
program
@@ -86,14 +86,14 @@ export function createCli(): Command {
.option('--label <key=value>', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, [])
.option('--output-schema <schema>', 'Output JSON matching the provided schema file path or inline JSON schema')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runRunCommand))
program
.command('attach')
.description("Attach to a running agent's output stream")
.argument('<id>', 'Agent ID (or prefix)')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(runAttachCommand)
program
@@ -104,7 +104,7 @@ export function createCli(): Command {
.option('--tail <n>', 'Show last n entries')
.option('--filter <type>', 'Filter by event type (tools, text, errors, permissions)')
.option('--since <time>', 'Show logs since timestamp')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(runLogsCommand)
program
@@ -114,7 +114,7 @@ export function createCli(): Command {
.option('--all', 'Stop all agents')
.option('--cwd <path>', 'Stop all agents in directory')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runStopCommand))
program
@@ -124,7 +124,7 @@ export function createCli(): Command {
.option('--all', 'Delete all agents')
.option('--cwd <path>', 'Delete all agents in directory')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runDeleteCommand))
program
@@ -135,7 +135,7 @@ export function createCli(): Command {
.option('--no-wait', 'Return immediately without waiting for completion')
.option('--image <path>', 'Attach image(s) to the message', collectMultiple, [])
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runSendCommand))
program
@@ -143,7 +143,7 @@ export function createCli(): Command {
.description('Show detailed information about an agent')
.argument('<id>', 'Agent ID (or prefix)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runInspectCommand))
program
@@ -152,7 +152,7 @@ export function createCli(): Command {
.argument('<id>', 'Agent ID (or prefix)')
.option('--timeout <seconds>', 'Maximum wait time (default: no limit)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runWaitCommand))
// Top-level local daemon shortcuts
+12 -12
View File
@@ -30,7 +30,7 @@ export function createAgentCommand(): Command {
.option('--label <key=value>', 'Filter by label (can be used multiple times)', collectMultiple, [])
.option('--thinking <id>', 'Filter by thinking option ID')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runLsCommand))
agent
@@ -47,14 +47,14 @@ export function createAgentCommand(): Command {
.option('--label <key=value>', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, [])
.option('--output-schema <schema>', 'Output JSON matching the provided schema file path or inline JSON schema')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runRunCommand))
agent
.command('attach')
.description("Attach to a running agent's output stream")
.argument('<id>', 'Agent ID (or prefix)')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(runAttachCommand)
agent
@@ -64,7 +64,7 @@ export function createAgentCommand(): Command {
.option('-f, --follow', 'Follow log output (streaming)')
.option('--tail <n>', 'Show last n entries')
.option('--filter <type>', 'Filter by event type (tools, text, errors, permissions)')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(runLogsCommand)
agent
@@ -74,7 +74,7 @@ export function createAgentCommand(): Command {
.option('--all', 'Stop all agents')
.option('--cwd <path>', 'Stop all agents in directory')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runStopCommand))
agent
@@ -84,7 +84,7 @@ export function createAgentCommand(): Command {
.option('--all', 'Delete all agents')
.option('--cwd <path>', 'Delete all agents in directory')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runDeleteCommand))
agent
@@ -94,7 +94,7 @@ export function createAgentCommand(): Command {
.argument('<prompt>', 'The message to send')
.option('--no-wait', 'Return immediately without waiting for completion')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runSendCommand))
agent
@@ -102,7 +102,7 @@ export function createAgentCommand(): Command {
.description('Show detailed information about an agent')
.argument('<id>', 'Agent ID (or prefix)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runInspectCommand))
agent
@@ -111,7 +111,7 @@ export function createAgentCommand(): Command {
.argument('<id>', 'Agent ID (or prefix)')
.option('--timeout <seconds>', 'Maximum wait time (default: no limit)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runWaitCommand))
// Advanced agent commands (less common operations)
@@ -122,7 +122,7 @@ export function createAgentCommand(): Command {
.argument('[mode]', 'Mode to set (required unless --list)')
.option('--list', 'List available modes for this agent')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runModeCommand))
agent
@@ -131,7 +131,7 @@ export function createAgentCommand(): Command {
.argument('<id>', 'Agent ID (or prefix)')
.option('--force', 'Force archive running agent (interrupts active run first)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runArchiveCommand))
agent
@@ -146,7 +146,7 @@ export function createAgentCommand(): Command {
[]
)
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runUpdateCommand))
return agent
+3 -3
View File
@@ -11,7 +11,7 @@ export function createPermitCommand(): Command {
.command('ls')
.description('List all pending permissions')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runLsCommand))
permit
@@ -22,7 +22,7 @@ export function createPermitCommand(): Command {
.option('--all', 'Allow all pending permissions for this agent')
.option('--input <json>', 'Modified input parameters (JSON)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runAllowCommand))
permit
@@ -34,7 +34,7 @@ export function createPermitCommand(): Command {
.option('--message <msg>', 'Denial reason message')
.option('--interrupt', 'Stop agent after denial')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runDenyCommand))
return permit
+2 -2
View File
@@ -10,7 +10,7 @@ export function createProviderCommand(): Command {
.command('ls')
.description('List available providers and status')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runLsCommand))
provider
@@ -19,7 +19,7 @@ export function createProviderCommand(): Command {
.argument('<provider>', 'Provider name (claude, codex, opencode)')
.option('--thinking', 'Include thinking option IDs for each model')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runModelsCommand))
return provider
+2 -2
View File
@@ -14,7 +14,7 @@ export function createSpeechCommand(): Command {
.command("models")
.description("List local speech model download status")
.option("--json", "Output in JSON format")
.option("--host <host>", "Daemon host:port (default: localhost:6767)")
.option("--host <host>", "Daemon host target (default: local socket/pipe, then localhost:6767)")
.action(withOutput(runSpeechModelsCommand));
speech
@@ -22,7 +22,7 @@ export function createSpeechCommand(): Command {
.description("Download local speech models")
.option("--model <id>", "Model ID to download (repeatable)", collectMultiple, [])
.option("--json", "Output in JSON format")
.option("--host <host>", "Daemon host:port (default: localhost:6767)")
.option("--host <host>", "Daemon host target (default: local socket/pipe, then localhost:6767)")
.action(withOutput(runSpeechDownloadCommand));
return speech;
+2 -2
View File
@@ -10,7 +10,7 @@ export function createWorktreeCommand(): Command {
.command('ls')
.description('List Paseo-managed git worktrees')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runLsCommand))
worktree
@@ -18,7 +18,7 @@ export function createWorktreeCommand(): Command {
.description('Archive a worktree (removes worktree and associated branch)')
.argument('<name>', 'Worktree name or branch name')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runArchiveCommand))
return worktree
+146 -38
View File
@@ -1,4 +1,6 @@
import { DaemonClient } from '@getpaseo/server'
import { existsSync, readFileSync } from 'node:fs'
import { loadConfig, resolvePaseoHome, DaemonClient } from '@getpaseo/server'
import path from 'node:path'
import WebSocket from 'ws'
import { getOrCreateCliClientId } from './client-id.js'
@@ -9,6 +11,7 @@ export interface ConnectOptions {
const DEFAULT_HOST = 'localhost:6767'
const DEFAULT_TIMEOUT = 5000
const PID_FILENAME = 'paseo.pid'
type DaemonTarget =
| {
@@ -25,7 +28,106 @@ type DaemonTarget =
* Get the daemon host from environment or options
*/
export function getDaemonHost(options?: ConnectOptions): string {
return options?.host ?? process.env.PASEO_HOST ?? DEFAULT_HOST
return resolveDaemonHostCandidates(options)[0] ?? DEFAULT_HOST
}
export function normalizeDaemonHost(raw: string): string | null {
const trimmed = raw.trim()
if (!trimmed) {
return null
}
if (
trimmed.startsWith('unix://') ||
trimmed.startsWith('pipe://') ||
trimmed.startsWith('\\\\.\\pipe\\')
) {
return trimmed.startsWith('\\\\.\\pipe\\') ? `pipe://${trimmed}` : trimmed
}
if (path.isAbsolute(trimmed)) {
return `unix://${trimmed}`
}
if (/^\d+$/.test(trimmed)) {
return `127.0.0.1:${trimmed}`
}
return trimmed.includes(':') ? trimmed : null
}
export function resolveDefaultDaemonHost(env: NodeJS.ProcessEnv = process.env): string {
return resolveDefaultDaemonHosts(env)[0] ?? DEFAULT_HOST
}
function isIpcDaemonHost(host: string | null): host is string {
return host !== null && (host.startsWith('unix://') || host.startsWith('pipe://'))
}
function isTcpDaemonHost(host: string | null): host is string {
return host !== null && !isIpcDaemonHost(host)
}
function readPidSocketTarget(paseoHome: string): string | null {
const pidPath = path.join(paseoHome, PID_FILENAME)
if (!existsSync(pidPath)) {
return null
}
try {
const parsed = JSON.parse(readFileSync(pidPath, 'utf-8')) as { sockPath?: unknown }
return typeof parsed.sockPath === 'string' ? parsed.sockPath : null
} catch {
return null
}
}
function resolveConfiguredIpcDaemonHost(env: NodeJS.ProcessEnv, paseoHome: string): string | null {
const directEnvHost = normalizeDaemonHost(env.PASEO_LISTEN ?? '')
if (isIpcDaemonHost(directEnvHost)) {
return directEnvHost
}
const pidHost = normalizeDaemonHost(readPidSocketTarget(paseoHome) ?? '')
if (isIpcDaemonHost(pidHost)) {
return pidHost
}
const config = loadConfig(paseoHome, { env })
const configuredHost = normalizeDaemonHost(config.listen)
return isIpcDaemonHost(configuredHost) ? configuredHost : null
}
function resolveConfiguredTcpDaemonHost(env: NodeJS.ProcessEnv, paseoHome: string): string | null {
const configuredHost = normalizeDaemonHost(loadConfig(paseoHome, { env }).listen)
if (!isTcpDaemonHost(configuredHost)) {
return null
}
return configuredHost === '127.0.0.1:6767' ? null : configuredHost
}
export function resolveDefaultDaemonHosts(env: NodeJS.ProcessEnv = process.env): string[] {
const paseoHome = resolvePaseoHome(env)
const candidates: string[] = []
const configuredIpcHost = resolveConfiguredIpcDaemonHost(env, paseoHome)
if (configuredIpcHost) {
candidates.push(configuredIpcHost)
}
const configuredTcpHost = resolveConfiguredTcpDaemonHost(env, paseoHome)
if (configuredTcpHost) {
candidates.push(configuredTcpHost)
}
candidates.push(DEFAULT_HOST)
return Array.from(new Set(candidates))
}
function resolveDaemonHostCandidates(options?: ConnectOptions): string[] {
const explicitHost = options?.host ?? process.env.PASEO_HOST
if (explicitHost) {
return [explicitHost]
}
return resolveDefaultDaemonHosts()
}
export function resolveDaemonTarget(host: string): DaemonTarget {
@@ -86,50 +188,56 @@ function createNodeWebSocketFactory() {
* Returns the connected client or throws if connection fails
*/
export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonClient> {
const host = getDaemonHost(options)
const timeout = options?.timeout ?? DEFAULT_TIMEOUT
const clientId = await getOrCreateCliClientId()
const target = resolveDaemonTarget(host)
const hosts = resolveDaemonHostCandidates(options)
const nodeWebSocketFactory = createNodeWebSocketFactory()
let lastError: unknown = null
const client = new DaemonClient(
{
url: target.url,
clientId,
clientType: 'cli',
webSocketFactory: (url: string, config?: { headers?: Record<string, string> }) =>
nodeWebSocketFactory(url, {
headers: config?.headers,
...(target.type === 'ipc' ? { socketPath: target.socketPath } : {}),
}),
reconnect: { enabled: false },
} as unknown as ConstructorParameters<typeof DaemonClient>[0]
)
for (const host of hosts) {
const target = resolveDaemonTarget(host)
const client = new DaemonClient(
{
url: target.url,
clientId,
clientType: 'cli',
webSocketFactory: (url: string, config?: { headers?: Record<string, string> }) =>
nodeWebSocketFactory(url, {
headers: config?.headers,
...(target.type === 'ipc' ? { socketPath: target.socketPath } : {}),
}),
reconnect: { enabled: false },
} as unknown as ConstructorParameters<typeof DaemonClient>[0]
)
// Connect with timeout
const connectPromise = client.connect()
let timeoutHandle: ReturnType<typeof setTimeout> | null = null
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => {
reject(new Error(`Connection timeout after ${timeout}ms`))
}, timeout)
})
const connectPromise = client.connect()
let timeoutHandle: ReturnType<typeof setTimeout> | null = null
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => {
reject(new Error(`Connection timeout after ${timeout}ms`))
}, timeout)
})
try {
await Promise.race([connectPromise, timeoutPromise])
// Clear the timeout so it doesn't keep the event loop alive
if (timeoutHandle) {
clearTimeout(timeoutHandle)
try {
await Promise.race([connectPromise, timeoutPromise])
if (timeoutHandle) {
clearTimeout(timeoutHandle)
}
return client
} catch (err) {
if (timeoutHandle) {
clearTimeout(timeoutHandle)
}
lastError = err
await client.close().catch(() => {})
}
return client
} catch (err) {
// Clear the timeout on error too
if (timeoutHandle) {
clearTimeout(timeoutHandle)
}
await client.close().catch(() => {})
throw err
}
if (lastError instanceof Error) {
throw lastError
}
throw new Error(`Unable to connect to Paseo daemon via ${hosts.join(', ')}`)
}
/**
@@ -1,7 +1,15 @@
#!/usr/bin/env npx tsx
import assert from 'node:assert'
import { resolveDaemonTarget } from '../src/utils/client.js'
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import {
getDaemonHost,
normalizeDaemonHost,
resolveDaemonTarget,
resolveDefaultDaemonHosts,
} from '../src/utils/client.js'
console.log('=== CLI IPC Target Helpers ===\n')
@@ -27,4 +35,77 @@ console.log('=== CLI IPC Target Helpers ===\n')
console.log('✓ pipe hosts preserve Node socketPath transport form\n')
}
{
console.log('Test 3: local unix socket paths normalize into IPC daemon targets')
assert.strictEqual(normalizeDaemonHost('/tmp/paseo.sock'), 'unix:///tmp/paseo.sock')
console.log('✓ local unix socket paths normalize into IPC daemon targets\n')
}
{
console.log('Test 4: default host resolution tries local IPC first, then localhost fallback')
const paseoHome = mkdtempSync(path.join(os.tmpdir(), 'paseo-client-targets-'))
try {
mkdirSync(paseoHome, { recursive: true })
writeFileSync(
path.join(paseoHome, 'paseo.pid'),
JSON.stringify({ pid: process.pid, sockPath: '/tmp/paseo-from-pid.sock' })
)
assert.deepStrictEqual(resolveDefaultDaemonHosts({ PASEO_HOME: paseoHome }), [
'unix:///tmp/paseo-from-pid.sock',
'localhost:6767',
])
const previousHome = process.env.PASEO_HOME
const previousHost = process.env.PASEO_HOST
process.env.PASEO_HOME = paseoHome
delete process.env.PASEO_HOST
assert.strictEqual(getDaemonHost(), 'unix:///tmp/paseo-from-pid.sock')
if (previousHome === undefined) delete process.env.PASEO_HOME
else process.env.PASEO_HOME = previousHome
if (previousHost === undefined) delete process.env.PASEO_HOST
else process.env.PASEO_HOST = previousHost
} finally {
rmSync(paseoHome, { recursive: true, force: true })
}
console.log('✓ default host resolution tries local IPC first, then localhost fallback\n')
}
{
console.log('Test 5: configured TCP host is preserved before the localhost fallback')
const paseoHome = mkdtempSync(path.join(os.tmpdir(), 'paseo-client-targets-tcp-'))
try {
assert.deepStrictEqual(
resolveDefaultDaemonHosts({
PASEO_HOME: paseoHome,
PASEO_LISTEN: '127.0.0.1:7777',
}),
['127.0.0.1:7777', 'localhost:6767']
)
} finally {
rmSync(paseoHome, { recursive: true, force: true })
}
console.log('✓ configured TCP host is preserved before the localhost fallback\n')
}
{
console.log('Test 6: local IPC still takes priority over configured TCP hosts')
const paseoHome = mkdtempSync(path.join(os.tmpdir(), 'paseo-client-targets-order-'))
try {
mkdirSync(paseoHome, { recursive: true })
writeFileSync(
path.join(paseoHome, 'paseo.pid'),
JSON.stringify({ pid: process.pid, sockPath: '/tmp/paseo-priority.sock' })
)
assert.deepStrictEqual(
resolveDefaultDaemonHosts({
PASEO_HOME: paseoHome,
PASEO_LISTEN: '127.0.0.1:7777',
}),
['unix:///tmp/paseo-priority.sock', '127.0.0.1:7777', 'localhost:6767']
)
} finally {
rmSync(paseoHome, { recursive: true, force: true })
}
console.log('✓ local IPC still takes priority over configured TCP hosts\n')
}
console.log('=== All CLI IPC target tests passed ===')
+32 -20
View File
@@ -6,21 +6,19 @@ use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, Submenu};
#[cfg(target_os = "macos")]
use tauri::menu::AboutMetadata;
use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, Submenu};
use tauri::{AppHandle, Manager, WebviewWindow};
use tauri_plugin_updater::UpdaterExt;
mod runtime_manager;
pub use runtime_manager::try_run_cli_shim_from_args;
use runtime_manager::{
close_local_daemon_transport, ensure_managed_runtime, install_cli_shim,
managed_daemon_logs, managed_daemon_pairing, managed_daemon_status, managed_runtime_status,
open_local_daemon_transport, restart_managed_daemon,
send_local_daemon_transport_message, start_managed_daemon, stop_managed_daemon,
uninstall_cli_shim, update_managed_daemon_tcp_settings, ManagedTcpSettings,
LocalTransportState,
close_local_daemon_transport, ensure_managed_runtime, install_cli_shim, managed_daemon_logs,
managed_daemon_pairing, managed_daemon_status, managed_runtime_status,
open_local_daemon_transport, restart_managed_daemon, send_local_daemon_transport_message,
start_managed_daemon, stop_managed_daemon, uninstall_cli_shim,
update_managed_daemon_tcp_settings, LocalTransportState, ManagedTcpSettings,
};
// Store zoom as u64 bits (f64 * 100 as integer for atomic ops)
@@ -95,7 +93,11 @@ fn parse_managed_headless_command() -> Result<Option<ManagedHeadlessCommand>, St
let command = args
.get(flag_index + 1)
.ok_or_else(|| "Missing command after --managed-headless".to_string())?;
let tail = args.iter().skip(flag_index + 2).cloned().collect::<Vec<_>>();
let tail = args
.iter()
.skip(flag_index + 2)
.cloned()
.collect::<Vec<_>>();
match command.as_str() {
"runtime-status" => Ok(Some(ManagedHeadlessCommand::RuntimeStatus)),
"bootstrap" => Ok(Some(ManagedHeadlessCommand::Bootstrap)),
@@ -134,15 +136,19 @@ fn parse_managed_headless_command() -> Result<Option<ManagedHeadlessCommand>, St
index += 2;
}
other => {
return Err(format!("Unknown --managed-headless update-tcp option: {other}"));
return Err(format!(
"Unknown --managed-headless update-tcp option: {other}"
));
}
}
}
Ok(Some(ManagedHeadlessCommand::UpdateTcp(ManagedTcpSettings {
enabled,
host,
port,
})))
Ok(Some(ManagedHeadlessCommand::UpdateTcp(
ManagedTcpSettings {
enabled,
host,
port,
},
)))
}
other => Err(format!("Unknown --managed-headless command: {other}")),
}
@@ -198,8 +204,9 @@ fn maybe_run_managed_headless_command(app: &AppHandle) -> Result<bool, String> {
Err(error) => {
eprintln!(
"{}",
serde_json::to_string_pretty(&json!({ "error": error }))
.unwrap_or_else(|_| r#"{"error":"managed headless command failed"}"#.to_string())
serde_json::to_string_pretty(&json!({ "error": error })).unwrap_or_else(|_| {
r#"{"error":"managed headless command failed"}"#.to_string()
})
);
app_handle.exit(1);
}
@@ -244,7 +251,10 @@ fi"#;
LocalDaemonVersionResult {
version: None,
error: Some(if stderr.is_empty() {
format!("paseo --version exited with code {}", output.status.code().unwrap_or(1))
format!(
"paseo --version exited with code {}",
output.status.code().unwrap_or(1)
)
} else {
stderr
}),
@@ -557,7 +567,8 @@ async fn garbage_collect_attachment_files(
let entries = fs::read_dir(&attachment_dir)
.map_err(|error| format!("Failed to scan attachment directory: {error}"))?;
for entry in entries {
let entry = entry.map_err(|error| format!("Failed to read directory entry: {error}"))?;
let entry =
entry.map_err(|error| format!("Failed to read directory entry: {error}"))?;
let path = entry.path();
if !path.is_file() {
continue;
@@ -651,7 +662,8 @@ pub fn run() {
copyright: app.config().bundle.copyright.clone(),
..Default::default()
};
let about = PredefinedMenuItem::about(app.handle(), None, Some(about_metadata))?;
let about =
PredefinedMenuItem::about(app.handle(), None, Some(about_metadata))?;
if submenu.remove_at(0)?.is_some() {
submenu.insert(&about, 0)?;
-4
View File
@@ -8,9 +8,5 @@
tauri::embed_plist::embed_info_plist!("../Info.plist");
fn main() {
if let Err(error) = paseo_lib::try_run_cli_shim_from_args() {
eprintln!("{error}");
std::process::exit(1);
}
paseo_lib::run();
}
+502 -146
View File
@@ -6,6 +6,7 @@ use std::collections::HashMap;
use std::env;
use std::fs;
use std::io;
use std::iter;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
@@ -29,6 +30,13 @@ const PIPE_CLIENT_URL: &str = "ws://localhost/ws";
const PIPE_PREFIX: &str = r"\\.\pipe\";
const DEFAULT_MANAGED_TCP_HOST: &str = "127.0.0.1";
const DEFAULT_MANAGED_TCP_PORT: u16 = 7771;
const CLI_SHIM_NAME: &str = "paseo";
#[cfg(windows)]
const CLI_SHIM_WINDOWS_NAME: &str = "paseo.cmd";
const CLI_INNER_DIR: &str = "bin";
const CLI_INNER_UNIX_NAME: &str = "paseo-managed";
#[cfg(windows)]
const CLI_INNER_WINDOWS_NAME: &str = "paseo-managed.cmd";
static RUNTIME_INSTALL_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
@@ -77,6 +85,7 @@ struct PidFile {
#[derive(Debug, Clone)]
struct ManagedPaths {
runtime_root: PathBuf,
stable_runtime_root: PathBuf,
managed_home: PathBuf,
transport_path: PathBuf,
logs_path: PathBuf,
@@ -152,9 +161,19 @@ pub struct ManagedPairingOffer {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CliShimResult {
pub status: String,
pub installed: bool,
pub path: Option<String>,
pub message: String,
pub manual_instructions: Option<CliManualInstructions>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CliManualInstructions {
pub title: String,
pub detail: String,
pub commands: String,
}
#[derive(Debug, Clone, Serialize)]
@@ -189,17 +208,24 @@ impl Default for LocalTransportState {
impl LocalTransportState {
fn alloc_session_id(&self) -> String {
format!("local-session-{}", self.next_session_id.fetch_add(1, Ordering::Relaxed))
format!(
"local-session-{}",
self.next_session_id.fetch_add(1, Ordering::Relaxed)
)
}
}
#[cfg(unix)]
fn build_local_websocket_request(url: &str) -> Result<Request<()>, tokio_tungstenite::tungstenite::Error> {
fn build_local_websocket_request(
url: &str,
) -> Result<Request<()>, tokio_tungstenite::tungstenite::Error> {
url.into_client_request()
}
#[cfg(windows)]
fn build_local_websocket_request(url: &str) -> Result<Request<()>, tokio_tungstenite::tungstenite::Error> {
fn build_local_websocket_request(
url: &str,
) -> Result<Request<()>, tokio_tungstenite::tungstenite::Error> {
url.into_client_request()
}
@@ -268,7 +294,10 @@ mod tests {
assert_eq!(request.uri().to_string(), local_client_url());
assert_eq!(
request.headers().get("host").and_then(|value| value.to_str().ok()),
request
.headers()
.get("host")
.and_then(|value| value.to_str().ok()),
Some("localhost")
);
assert!(request.headers().contains_key("sec-websocket-key"));
@@ -280,7 +309,10 @@ mod tests {
Some("13")
);
assert_eq!(
request.headers().get("upgrade").and_then(|value| value.to_str().ok()),
request
.headers()
.get("upgrade")
.and_then(|value| value.to_str().ok()),
Some("websocket")
);
assert_eq!(
@@ -292,6 +324,72 @@ mod tests {
);
}
#[test]
fn cli_shim_result_serializes_manual_instructions_with_camel_case_keys() {
let value = serde_json::to_value(CliShimResult {
status: "manualInstallRequired".to_string(),
installed: false,
path: Some("/usr/local/bin/paseo".to_string()),
message: "Manual install required.".to_string(),
manual_instructions: Some(CliManualInstructions {
title: "Install from Terminal".to_string(),
detail: "Run the commands below.".to_string(),
commands: "sudo ...".to_string(),
}),
})
.expect("serializes");
assert_eq!(
value.get("status").and_then(|entry| entry.as_str()),
Some("manualInstallRequired")
);
assert_eq!(
value
.get("manualInstructions")
.and_then(|entry| entry.get("commands"))
.and_then(|entry| entry.as_str()),
Some("sudo ...")
);
}
#[test]
fn outer_cli_shim_contents_only_forwards_to_inner_launcher() {
let contents = outer_cli_shim_contents(Path::new(
"/Applications/Paseo.app/Contents/MacOS/paseo-managed",
));
assert!(contents
.contains("exec \"/Applications/Paseo.app/Contents/MacOS/paseo-managed\" \"$@\""));
assert!(!contents.contains("--paseo-cli-shim"));
}
#[test]
fn inner_cli_launcher_path_lives_inside_managed_runtime_tree() {
let runtime_root = PathBuf::from("/tmp/paseo-desktop/runtime");
let inner_launcher = managed_cli_inner_launcher_path(&runtime_root);
assert_eq!(
inner_launcher,
PathBuf::from("/tmp/paseo-desktop/runtime/bin/paseo-managed")
);
}
#[test]
fn outer_shim_target_remains_stable_across_runtime_updates() {
let runtime_root = PathBuf::from("/tmp/paseo-desktop/runtime");
let first_runtime_root = PathBuf::from("/tmp/paseo-desktop/runtime/runtime-a");
let second_runtime_root = PathBuf::from("/tmp/paseo-desktop/runtime/runtime-b");
let first_target = managed_cli_inner_launcher_path(&runtime_root);
let second_target = managed_cli_inner_launcher_path(&runtime_root);
let first_contents = outer_cli_shim_contents(&first_target);
let second_contents = outer_cli_shim_contents(&second_target);
assert_ne!(first_runtime_root, second_runtime_root);
assert_eq!(first_target, second_target);
assert_eq!(first_contents, second_contents);
assert!(!first_contents.contains(first_runtime_root.to_string_lossy().as_ref()));
assert!(!first_contents.contains(second_runtime_root.to_string_lossy().as_ref()));
}
#[cfg(unix)]
#[test]
#[ignore = "requires a running local daemon socket"]
@@ -301,7 +399,10 @@ mod tests {
.filter(|value| !value.trim().is_empty())
.map(PathBuf::from)
.or_else(|| {
dirs::home_dir().map(|home| home.join(DEFAULT_MANAGED_HOME_BASENAME).join(SHORT_SOCKET_FILENAME))
dirs::home_dir().map(|home| {
home.join(DEFAULT_MANAGED_HOME_BASENAME)
.join(SHORT_SOCKET_FILENAME)
})
})
.expect("socket path should resolve");
@@ -345,10 +446,16 @@ fn copy_dir_recursive(source: &Path, target: &Path) -> Result<(), String> {
.map_err(|error| format!("Failed to inspect {}: {error}", source_path.display()))?;
if file_type.is_symlink() {
let resolved = fs::canonicalize(&source_path).map_err(|error| {
format!("Failed to resolve symlink {}: {error}", source_path.display())
format!(
"Failed to resolve symlink {}: {error}",
source_path.display()
)
})?;
let resolved_type = fs::metadata(&resolved).map_err(|error| {
format!("Failed to inspect resolved symlink {}: {error}", resolved.display())
format!(
"Failed to inspect resolved symlink {}: {error}",
resolved.display()
)
})?;
if resolved_type.is_dir() {
copy_dir_recursive(&resolved, &target_path)?;
@@ -390,7 +497,12 @@ fn copy_dir_recursive(source: &Path, target: &Path) -> Result<(), String> {
.permissions()
.mode();
fs::set_permissions(&target_path, fs::Permissions::from_mode(permissions)).map_err(
|error| format!("Failed to preserve mode on {}: {error}", target_path.display()),
|error| {
format!(
"Failed to preserve mode on {}: {error}",
target_path.display()
)
},
)?;
}
}
@@ -514,6 +626,7 @@ fn resolve_paths(app: &AppHandle, runtime_id: &str) -> Result<ManagedPaths, Stri
.unwrap_or_else(|| default_transport_path(&managed_home, &test_root));
return Ok(ManagedPaths {
runtime_root,
stable_runtime_root: test_root.join("runtime"),
managed_home: managed_home.clone(),
transport_path,
logs_path: managed_home.join("daemon.log"),
@@ -525,21 +638,21 @@ fn resolve_paths(app: &AppHandle, runtime_id: &str) -> Result<ManagedPaths, Stri
let root = app_data_root(app)?;
let runtime_root = resolve_override_path("PASEO_DESKTOP_MANAGED_RUNTIME_ROOT")
.unwrap_or_else(|| root.join("runtime").join(runtime_id));
let managed_home = resolve_override_path("PASEO_DESKTOP_MANAGED_HOME")
.unwrap_or_else(|| {
#[cfg(target_os = "macos")]
{
default_managed_home()
}
#[cfg(not(target_os = "macos"))]
{
default_managed_home(&root)
}
});
let managed_home = resolve_override_path("PASEO_DESKTOP_MANAGED_HOME").unwrap_or_else(|| {
#[cfg(target_os = "macos")]
{
default_managed_home()
}
#[cfg(not(target_os = "macos"))]
{
default_managed_home(&root)
}
});
let transport_path = resolve_override_path("PASEO_DESKTOP_MANAGED_SOCKET_PATH")
.unwrap_or_else(|| default_transport_path(&managed_home, &root));
Ok(ManagedPaths {
runtime_root,
stable_runtime_root: root.join("runtime"),
managed_home: managed_home.clone(),
transport_path,
logs_path: managed_home.join("daemon.log"),
@@ -566,7 +679,9 @@ fn bundled_runtime_root(app: &AppHandle) -> Result<PathBuf, String> {
Err("Managed runtime resources are not bundled with this desktop build.".to_string())
}
fn load_bundled_runtime_pointer(app: &AppHandle) -> Result<(PathBuf, BundledRuntimePointer), String> {
fn load_bundled_runtime_pointer(
app: &AppHandle,
) -> Result<(PathBuf, BundledRuntimePointer), String> {
let root = bundled_runtime_root(app)?;
let pointer_path = root.join("current-runtime.json");
let pointer = read_json_file::<BundledRuntimePointer>(&pointer_path)?;
@@ -645,8 +760,10 @@ fn resolve_tcp_settings_from_state(state: Option<&ManagedStateFile>) -> ManagedT
let listen = state
.and_then(|value| value.tcp_listen.clone())
.unwrap_or_else(|| format!("{DEFAULT_MANAGED_TCP_HOST}:{DEFAULT_MANAGED_TCP_PORT}"));
let (host, port) =
parse_tcp_listen(&listen).unwrap_or((DEFAULT_MANAGED_TCP_HOST.to_string(), DEFAULT_MANAGED_TCP_PORT));
let (host, port) = parse_tcp_listen(&listen).unwrap_or((
DEFAULT_MANAGED_TCP_HOST.to_string(),
DEFAULT_MANAGED_TCP_PORT,
));
ManagedTcpSettings {
enabled: state.map(|value| value.tcp_enabled).unwrap_or(false),
host,
@@ -681,10 +798,11 @@ fn cli_host_for_target(target: &ManagedTransportTarget) -> String {
fn cli_env(paths: &ManagedPaths) -> Vec<(String, String)> {
let state = read_state_file(&paths.state_file_path);
let target = managed_transport_target(paths, state.as_ref()).unwrap_or(ManagedTransportTarget {
transport_type: default_transport_type().to_string(),
transport_path: paths.transport_path.to_string_lossy().into_owned(),
});
let target =
managed_transport_target(paths, state.as_ref()).unwrap_or(ManagedTransportTarget {
transport_type: default_transport_type().to_string(),
transport_path: paths.transport_path.to_string_lossy().into_owned(),
});
vec![
(
"PASEO_HOME".to_string(),
@@ -703,10 +821,16 @@ fn cli_command(
let node = runtime_root.join(&manifest.node_relative_path);
let cli = runtime_root.join(&manifest.cli_entrypoint_relative_path);
if !node.exists() {
return Err(format!("Bundled Node runtime is missing at {}", node.display()));
return Err(format!(
"Bundled Node runtime is missing at {}",
node.display()
));
}
if !cli.exists() {
return Err(format!("Bundled CLI entrypoint is missing at {}", cli.display()));
return Err(format!(
"Bundled CLI entrypoint is missing at {}",
cli.display()
));
}
let mut command = Command::new(node);
command.arg(cli);
@@ -717,6 +841,177 @@ fn cli_command(
Ok(command)
}
fn shell_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', r"'\''"))
}
fn escape_applescript_string(value: &str) -> String {
value.replace('\\', r"\\").replace('"', "\\\"")
}
#[cfg(windows)]
fn powershell_double_quote(value: &str) -> String {
value.replace('`', "``").replace('"', "`\"")
}
fn managed_cli_inner_launcher_path(launcher_root: &Path) -> PathBuf {
#[cfg(windows)]
{
return launcher_root
.join(CLI_INNER_DIR)
.join(CLI_INNER_WINDOWS_NAME);
}
#[cfg(not(windows))]
{
launcher_root.join(CLI_INNER_DIR).join(CLI_INNER_UNIX_NAME)
}
}
fn outer_cli_shim_path() -> Result<PathBuf, String> {
#[cfg(target_os = "macos")]
{
return Ok(PathBuf::from("/usr/local/bin").join(CLI_SHIM_NAME));
}
#[cfg(all(not(target_os = "macos"), not(windows)))]
{
return Ok(PathBuf::from("/usr/local/bin").join(CLI_SHIM_NAME));
}
#[cfg(windows)]
{
let local_app_data = dirs::data_local_dir().ok_or_else(|| {
"Failed to resolve LocalAppData for CLI shim instructions.".to_string()
})?;
Ok(local_app_data
.join("Microsoft")
.join("WinGet")
.join("Links")
.join(CLI_SHIM_WINDOWS_NAME))
}
}
fn inner_cli_launcher_contents(paths: &ManagedPaths, manifest: &ManagedRuntimeManifest) -> String {
let node = paths.runtime_root.join(&manifest.node_relative_path);
let cli = paths
.runtime_root
.join(&manifest.cli_entrypoint_relative_path);
#[cfg(windows)]
{
return format!(
"@echo off\r\nset \"PASEO_HOME={}\"\r\n\"{}\" \"{}\" %*\r\n",
paths.managed_home.display(),
node.display(),
cli.display()
);
}
#[cfg(not(windows))]
{
format!(
"#!/bin/sh\nexport PASEO_HOME={}\nexec \"{}\" \"{}\" \"$@\"\n",
shell_single_quote(paths.managed_home.to_string_lossy().as_ref()),
node.display(),
cli.display()
)
}
}
fn outer_cli_shim_contents(inner_launcher: &Path) -> String {
#[cfg(windows)]
{
return format!("@echo off\r\n\"{}\" %*\r\n", inner_launcher.display());
}
#[cfg(not(windows))]
{
format!("#!/bin/sh\nexec \"{}\" \"$@\"\n", inner_launcher.display())
}
}
fn write_cli_launcher(path: &Path, contents: &str) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("Failed to create {}: {error}", parent.display()))?;
}
fs::write(path, contents)
.map_err(|error| format!("Failed to write {}: {error}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o755))
.map_err(|error| format!("Failed to chmod {}: {error}", path.display()))?;
}
Ok(())
}
fn ensure_inner_cli_launcher(
paths: &ManagedPaths,
manifest: &ManagedRuntimeManifest,
) -> Result<PathBuf, String> {
let inner_launcher = managed_cli_inner_launcher_path(&paths.stable_runtime_root);
write_cli_launcher(
&inner_launcher,
&inner_cli_launcher_contents(paths, manifest),
)?;
Ok(inner_launcher)
}
fn cli_manual_instructions(
paths: &ManagedPaths,
manifest: &ManagedRuntimeManifest,
) -> Result<CliManualInstructions, String> {
let inner_launcher = ensure_inner_cli_launcher(paths, manifest)?;
let outer_shim = outer_cli_shim_path()?;
#[cfg(windows)]
{
let target_dir = outer_shim
.parent()
.ok_or_else(|| "CLI shim target is missing a parent directory.".to_string())?;
let contents = outer_cli_shim_contents(&inner_launcher);
return Ok(CliManualInstructions {
title: "Install the Paseo CLI from PowerShell".to_string(),
detail: "If the automatic install does not complete, run these commands in PowerShell to write the PATH shim manually.".to_string(),
commands: format!(
"$target = \"{}\"\nNew-Item -ItemType Directory -Force -Path \"{}\" | Out-Null\n$contents = @\"\n{}\"@\nSet-Content -Path $target -Value $contents -Encoding ASCII\n",
powershell_double_quote(outer_shim.to_string_lossy().as_ref()),
powershell_double_quote(target_dir.to_string_lossy().as_ref()),
contents
),
});
}
#[cfg(not(windows))]
{
Ok(CliManualInstructions {
title: "Install the Paseo CLI from Terminal".to_string(),
detail: "If the automatic install does not complete, run these commands in Terminal to write the global PATH shim manually.".to_string(),
commands: format!(
"sudo mkdir -p {target_dir}\nprintf '%s\\n' {lines} | sudo tee {target_path} >/dev/null\nsudo chmod 755 {target_path}\n",
target_dir = shell_single_quote(
outer_shim
.parent()
.ok_or_else(|| "CLI shim target is missing a parent directory.".to_string())?
.to_string_lossy()
.as_ref()
),
target_path = shell_single_quote(outer_shim.to_string_lossy().as_ref()),
lines = iter::once("#!/bin/sh".to_string())
.chain(iter::once(format!(
"exec \"{}\" \"$@\"",
inner_launcher.to_string_lossy()
)))
.map(|line| shell_single_quote(&line))
.collect::<Vec<_>>()
.join(" ")
),
})
}
}
fn detect_installed_cli_shim_path(paths: &ManagedPaths) -> Option<String> {
let outer_shim = outer_cli_shim_path().ok()?;
let inner_launcher = managed_cli_inner_launcher_path(&paths.stable_runtime_root);
let expected = outer_cli_shim_contents(&inner_launcher);
let actual = fs::read_to_string(&outer_shim).ok()?;
(actual == expected).then(|| outer_shim.to_string_lossy().into_owned())
}
fn read_pid_from_home(managed_home: &Path) -> Option<i64> {
read_json_file::<PidFile>(&managed_home.join("paseo.pid"))
.ok()
@@ -752,8 +1047,12 @@ fn install_runtime_if_needed(source_root: &Path, target_root: &Path) -> Result<(
}
if target_root.exists() {
fs::remove_dir_all(target_root)
.map_err(|error| format!("Failed to remove incomplete runtime {}: {error}", target_root.display()))?;
fs::remove_dir_all(target_root).map_err(|error| {
format!(
"Failed to remove incomplete runtime {}: {error}",
target_root.display()
)
})?;
}
let staging_root = target_root.with_extension(format!("installing-{}", std::process::id()));
@@ -800,6 +1099,7 @@ fn ensure_runtime_installed_internal(app: &AppHandle) -> Result<ManagedRuntimeSt
}
fs::create_dir_all(&paths.managed_home)
.map_err(|error| format!("Failed to create {}: {error}", paths.managed_home.display()))?;
ensure_inner_cli_launcher(&paths, &manifest)?;
let existing_state = read_state_file(&paths.state_file_path);
let target = managed_transport_target(&paths, existing_state.as_ref())?;
let state = ManagedStateFile {
@@ -812,7 +1112,9 @@ fn ensure_runtime_installed_internal(app: &AppHandle) -> Result<ManagedRuntimeSt
.as_ref()
.map(|entry| entry.tcp_enabled)
.unwrap_or(false),
tcp_listen: existing_state.as_ref().and_then(|entry| entry.tcp_listen.clone()),
tcp_listen: existing_state
.as_ref()
.and_then(|entry| entry.tcp_listen.clone()),
cli_shim_path: existing_state.and_then(|entry| entry.cli_shim_path),
};
write_state_file(&paths.state_file_path, &state)?;
@@ -868,7 +1170,13 @@ fn managed_daemon_status_internal(app: &AppHandle) -> Result<ManagedDaemonStatus
let cli_status = run_cli_json_command(
&paths.runtime_root,
&manifest,
&["daemon", "status", "--home", &paths.managed_home.to_string_lossy(), "--json"],
&[
"daemon",
"status",
"--home",
&paths.managed_home.to_string_lossy(),
"--json",
],
&paths,
)
.ok();
@@ -915,93 +1223,163 @@ fn managed_daemon_status_internal(app: &AppHandle) -> Result<ManagedDaemonStatus
tcp_listen: tcp_settings
.enabled
.then(|| format!("{}:{}", tcp_settings.host, tcp_settings.port)),
cli_shim_path: state.and_then(|entry| entry.cli_shim_path),
cli_shim_path: detect_installed_cli_shim_path(&paths),
})
}
fn cli_shim_contents(app_exe: &Path, state_file: &Path) -> String {
format!(
"#!/usr/bin/env sh\nexec \"{}\" --paseo-cli-shim \"{}\" \"$@\"\n",
app_exe.display(),
state_file.display()
)
#[cfg(target_os = "macos")]
fn install_cli_shim_via_macos_prompt(shim_path: &Path, contents: &str) -> Result<(), String> {
// Manual smoke path: click Install CLI in the desktop app on macOS and confirm the
// /usr/local/bin/paseo prompt writes the trivial outer shim shown in cli_manual_instructions().
let temp_path = std::env::temp_dir().join(format!("paseo-cli-shim-{}", std::process::id()));
write_cli_launcher(&temp_path, contents)?;
let command = format!(
"mkdir -p {target_dir} && install -m 755 {temp_path} {target_path}",
target_dir = shell_single_quote(
shim_path
.parent()
.ok_or_else(|| "CLI shim target is missing a parent directory.".to_string())?
.to_string_lossy()
.as_ref()
),
temp_path = shell_single_quote(temp_path.to_string_lossy().as_ref()),
target_path = shell_single_quote(shim_path.to_string_lossy().as_ref())
);
let output = Command::new("osascript")
.arg("-e")
.arg(format!(
"do shell script \"{}\" with administrator privileges",
escape_applescript_string(&command)
))
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.map_err(|error| format!("Failed to request macOS CLI install privileges: {error}"))?;
let _ = fs::remove_file(&temp_path);
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.contains("User canceled") || stderr.contains("(-128)") {
return Err("ELEVATION_DENIED".to_string());
}
Err(if stderr.is_empty() {
"Failed to install the global CLI shim.".to_string()
} else {
stderr
})
}
#[cfg(target_os = "macos")]
fn remove_cli_shim_via_macos_prompt(shim_path: &Path) -> Result<(), String> {
let command = format!(
"rm -f {}",
shell_single_quote(shim_path.to_string_lossy().as_ref())
);
let output = Command::new("osascript")
.arg("-e")
.arg(format!(
"do shell script \"{}\" with administrator privileges",
escape_applescript_string(&command)
))
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.map_err(|error| format!("Failed to request macOS CLI uninstall privileges: {error}"))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
Err(if stderr.is_empty() {
"Failed to remove the global CLI shim.".to_string()
} else {
stderr
})
}
fn install_cli_shim_internal(app: &AppHandle) -> Result<CliShimResult, String> {
let status = ensure_runtime_installed_internal(app)?;
let paths = resolve_paths(app, &status.runtime_id)?;
let bin_dir = if cfg!(target_os = "windows") {
dirs::data_dir()
.ok_or_else(|| "Failed to resolve data directory for CLI shim.".to_string())?
.join("paseo")
.join("bin")
} else {
dirs::home_dir()
.ok_or_else(|| "Failed to resolve home directory for CLI shim.".to_string())?
.join(".local")
.join("bin")
};
fs::create_dir_all(&bin_dir)
.map_err(|error| format!("Failed to create {}: {error}", bin_dir.display()))?;
let shim_path = if cfg!(target_os = "windows") {
bin_dir.join("paseo.cmd")
} else {
bin_dir.join("paseo")
};
let app_exe = std::env::current_exe()
.map_err(|error| format!("Failed to resolve desktop executable: {error}"))?;
if cfg!(target_os = "windows") {
fs::write(
&shim_path,
format!(
"@echo off\r\n\"{}\" --paseo-cli-shim \"{}\" %*\r\n",
app_exe.display(),
paths.state_file_path.display()
),
)
.map_err(|error| format!("Failed to write {}: {error}", shim_path.display()))?;
} else {
fs::write(&shim_path, cli_shim_contents(&app_exe, &paths.state_file_path))
.map_err(|error| format!("Failed to write {}: {error}", shim_path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&shim_path, fs::Permissions::from_mode(0o755)).map_err(
|error| format!("Failed to chmod {}: {error}", shim_path.display()),
)?;
let manifest = load_runtime_manifest(&paths.runtime_root)?;
let inner_launcher = ensure_inner_cli_launcher(&paths, &manifest)?;
let shim_path = outer_cli_shim_path()?;
let shim_contents = outer_cli_shim_contents(&inner_launcher);
let manual_instructions = cli_manual_instructions(&paths, &manifest)?;
#[cfg(target_os = "macos")]
let install_result = install_cli_shim_via_macos_prompt(&shim_path, &shim_contents);
#[cfg(all(not(target_os = "macos"), not(windows)))]
let install_result = write_cli_launcher(&shim_path, &shim_contents);
#[cfg(windows)]
let install_result: Result<(), String> = Err("AUTOMATIC_INSTALL_UNAVAILABLE".to_string());
match install_result {
Ok(()) => {
let mut state = read_state_file(&paths.state_file_path).unwrap_or(ManagedStateFile {
runtime_id: status.runtime_id.clone(),
runtime_root: paths.runtime_root.to_string_lossy().into_owned(),
managed_home: paths.managed_home.to_string_lossy().into_owned(),
transport_type: status.transport_type.clone(),
transport_path: status.transport_path.clone(),
tcp_enabled: false,
tcp_listen: None,
cli_shim_path: None,
});
state.cli_shim_path = Some(shim_path.to_string_lossy().into_owned());
write_state_file(&paths.state_file_path, &state)?;
Ok(CliShimResult {
status: "installed".to_string(),
installed: true,
path: Some(shim_path.to_string_lossy().into_owned()),
message: format!("Paseo CLI installed at {}.", shim_path.display()),
manual_instructions: None,
})
}
Err(error) if error == "ELEVATION_DENIED" => Ok(CliShimResult {
status: "elevationDenied".to_string(),
installed: false,
path: Some(shim_path.to_string_lossy().into_owned()),
message: "CLI install needs administrator approval. If you dismissed the prompt, install it from Terminal with the commands below.".to_string(),
manual_instructions: Some(manual_instructions),
}),
Err(error) if error == "AUTOMATIC_INSTALL_UNAVAILABLE" => Ok(CliShimResult {
status: "automaticInstallUnavailable".to_string(),
installed: false,
path: Some(shim_path.to_string_lossy().into_owned()),
message: "Automatic CLI install is not available on this platform. Finish the install manually with the commands below.".to_string(),
manual_instructions: Some(manual_instructions),
}),
Err(error) => Ok(CliShimResult {
status: "manualInstallRequired".to_string(),
installed: false,
path: Some(shim_path.to_string_lossy().into_owned()),
message: format!(
"Automatic CLI install did not complete: {error}. Finish the install manually with the commands below."
),
manual_instructions: Some(manual_instructions),
}),
}
let mut state = read_state_file(&paths.state_file_path).unwrap_or(ManagedStateFile {
runtime_id: status.runtime_id.clone(),
runtime_root: paths.runtime_root.to_string_lossy().into_owned(),
managed_home: paths.managed_home.to_string_lossy().into_owned(),
transport_type: status.transport_type.clone(),
transport_path: status.transport_path.clone(),
tcp_enabled: false,
tcp_listen: None,
cli_shim_path: None,
});
state.cli_shim_path = Some(shim_path.to_string_lossy().into_owned());
write_state_file(&paths.state_file_path, &state)?;
Ok(CliShimResult {
installed: true,
path: Some(shim_path.to_string_lossy().into_owned()),
message: "CLI shim installed for the current user.".to_string(),
})
}
fn uninstall_cli_shim_internal(app: &AppHandle) -> Result<CliShimResult, String> {
let status = ensure_runtime_installed_internal(app)?;
let paths = resolve_paths(app, &status.runtime_id)?;
let shim_path = read_state_file(&paths.state_file_path).and_then(|entry| entry.cli_shim_path);
if let Some(shim_path) = shim_path.clone() {
let shim_path_buf = PathBuf::from(&shim_path);
if shim_path_buf.exists() {
fs::remove_file(&shim_path_buf)
.map_err(|error| format!("Failed to remove {}: {error}", shim_path_buf.display()))?;
}
let shim_path = detect_installed_cli_shim_path(&paths)
.map(PathBuf::from)
.or_else(|| {
read_state_file(&paths.state_file_path)
.and_then(|entry| entry.cli_shim_path.map(PathBuf::from))
})
.unwrap_or(outer_cli_shim_path()?);
if shim_path.exists() {
#[cfg(target_os = "macos")]
remove_cli_shim_via_macos_prompt(&shim_path)?;
#[cfg(all(not(target_os = "macos"), not(windows)))]
fs::remove_file(&shim_path)
.map_err(|error| format!("Failed to remove {}: {error}", shim_path.display()))?;
#[cfg(windows)]
fs::remove_file(&shim_path)
.map_err(|error| format!("Failed to remove {}: {error}", shim_path.display()))?;
}
let mut state = read_state_file(&paths.state_file_path).unwrap_or(ManagedStateFile {
runtime_id: status.runtime_id.clone(),
@@ -1017,46 +1395,14 @@ fn uninstall_cli_shim_internal(app: &AppHandle) -> Result<CliShimResult, String>
write_state_file(&paths.state_file_path, &state)?;
Ok(CliShimResult {
status: "removed".to_string(),
installed: false,
path: None,
message: "CLI shim removed.".to_string(),
path: Some(shim_path.to_string_lossy().into_owned()),
message: "Paseo CLI shim removed.".to_string(),
manual_instructions: None,
})
}
pub fn try_run_cli_shim_from_args() -> Result<bool, String> {
let args = env::args().collect::<Vec<_>>();
let Some(flag_index) = args.iter().position(|value| value == "--paseo-cli-shim") else {
return Ok(false);
};
let state_file = args
.get(flag_index + 1)
.ok_or_else(|| "Missing state file path after --paseo-cli-shim".to_string())?;
let passthrough_args = args
.iter()
.skip(flag_index + 2)
.cloned()
.collect::<Vec<_>>();
let state = read_json_file::<ManagedStateFile>(&PathBuf::from(state_file))?;
let manifest = load_runtime_manifest(&PathBuf::from(&state.runtime_root))?;
let node = PathBuf::from(&state.runtime_root).join(&manifest.node_relative_path);
let cli = PathBuf::from(&state.runtime_root).join(&manifest.cli_entrypoint_relative_path);
let mut command = Command::new(node);
command.arg(cli);
command.args(passthrough_args);
command.env("PASEO_HOME", state.managed_home);
command.env(
"PASEO_HOST",
cli_host_for_target(&ManagedTransportTarget {
transport_type: state.transport_type,
transport_path: state.transport_path,
}),
);
let status = command
.status()
.map_err(|error| format!("Failed to execute bundled CLI shim: {error}"))?;
std::process::exit(status.code().unwrap_or(1));
}
fn start_managed_daemon_internal(app: &AppHandle) -> Result<ManagedDaemonStatus, String> {
let status = ensure_runtime_installed_internal(app)?;
let paths = resolve_paths(app, &status.runtime_id)?;
@@ -1265,7 +1611,13 @@ pub async fn managed_daemon_pairing(app: AppHandle) -> Result<ManagedPairingOffe
let value = run_cli_json_command(
&paths.runtime_root,
&manifest,
&["daemon", "pair", "--home", &paths.managed_home.to_string_lossy(), "--json"],
&[
"daemon",
"pair",
"--home",
&paths.managed_home.to_string_lossy(),
"--json",
],
&paths,
)?;
serde_json::from_value::<ManagedPairingOffer>(value)
@@ -1294,9 +1646,11 @@ pub async fn update_managed_daemon_tcp_settings(
app: AppHandle,
settings: ManagedTcpSettings,
) -> Result<ManagedDaemonStatus, String> {
tauri::async_runtime::spawn_blocking(move || update_managed_tcp_settings_internal(&app, settings))
.await
.map_err(|error| format!("Managed daemon TCP settings task failed: {error}"))?
tauri::async_runtime::spawn_blocking(move || {
update_managed_tcp_settings_internal(&app, settings)
})
.await
.map_err(|error| format!("Managed daemon TCP settings task failed: {error}"))?
}
async fn spawn_local_transport_session<S>(
@@ -1464,7 +1818,9 @@ pub async fn open_local_daemon_transport(
{
let ws_stream = connect_local_socket(PathBuf::from(transport_path))
.await
.map_err(|error| format!("Failed to connect to local daemon socket: {error}"))?;
.map_err(|error| {
format!("Failed to connect to local daemon socket: {error}")
})?;
spawn_local_transport_session(app, transport_state, session_id, ws_stream).await
}
#[cfg(not(unix))]