feat: implement two-shim CLI install with fallback instructions
This commit is contained in:
@@ -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 ManagedDaemonLogs,
|
||||||
type ManagedPairingOffer,
|
type ManagedPairingOffer,
|
||||||
type ManagedDaemonStatus,
|
type ManagedDaemonStatus,
|
||||||
|
type CliManualInstructions,
|
||||||
} from "@/desktop/managed-runtime/managed-runtime";
|
} from "@/desktop/managed-runtime/managed-runtime";
|
||||||
|
|
||||||
export interface LocalDaemonSectionProps {
|
export interface LocalDaemonSectionProps {
|
||||||
@@ -37,12 +38,17 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
|||||||
const [isInstallingCli, setIsInstallingCli] = useState(false);
|
const [isInstallingCli, setIsInstallingCli] = useState(false);
|
||||||
const [isSavingTcpSettings, setIsSavingTcpSettings] = useState(false);
|
const [isSavingTcpSettings, setIsSavingTcpSettings] = useState(false);
|
||||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||||
|
const [cliStatusMessage, setCliStatusMessage] = useState<string | null>(null);
|
||||||
const [managedLogs, setManagedLogs] = useState<ManagedDaemonLogs | null>(null);
|
const [managedLogs, setManagedLogs] = useState<ManagedDaemonLogs | null>(null);
|
||||||
const [isTcpModalOpen, setIsTcpModalOpen] = useState(false);
|
const [isTcpModalOpen, setIsTcpModalOpen] = useState(false);
|
||||||
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
|
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
|
||||||
const [isPairingModalOpen, setIsPairingModalOpen] = useState(false);
|
const [isPairingModalOpen, setIsPairingModalOpen] = useState(false);
|
||||||
|
const [isCliInstallModalOpen, setIsCliInstallModalOpen] = useState(false);
|
||||||
const [isLoadingPairing, setIsLoadingPairing] = useState(false);
|
const [isLoadingPairing, setIsLoadingPairing] = useState(false);
|
||||||
const [pairingOffer, setPairingOffer] = useState<ManagedPairingOffer | null>(null);
|
const [pairingOffer, setPairingOffer] = useState<ManagedPairingOffer | null>(null);
|
||||||
|
const [cliInstallInstructions, setCliInstallInstructions] = useState<CliManualInstructions | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null);
|
const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null);
|
||||||
const [tcpHostInput, setTcpHostInput] = useState(DEFAULT_TCP_HOST);
|
const [tcpHostInput, setTcpHostInput] = useState(DEFAULT_TCP_HOST);
|
||||||
const [tcpPortInput, setTcpPortInput] = useState(String(DEFAULT_TCP_PORT));
|
const [tcpPortInput, setTcpPortInput] = useState(String(DEFAULT_TCP_PORT));
|
||||||
@@ -135,22 +141,48 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsInstallingCli(true);
|
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;
|
const action = managedStatus?.cliShimPath ? uninstallManagedCliShim : installManagedCliShim;
|
||||||
void action()
|
void action()
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
setStatusMessage(result.message);
|
setCliStatusMessage(result.message);
|
||||||
|
if (result.manualInstructions) {
|
||||||
|
setCliInstallInstructions(result.manualInstructions);
|
||||||
|
setIsCliInstallModalOpen(true);
|
||||||
|
} else {
|
||||||
|
setCliInstallInstructions(null);
|
||||||
|
setIsCliInstallModalOpen(false);
|
||||||
|
}
|
||||||
return loadManagedStatus();
|
return loadManagedStatus();
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
setStatusMessage(`CLI shim action failed: ${message}`);
|
setCliStatusMessage(`CLI shim action failed: ${message}`);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setIsInstallingCli(false);
|
setIsInstallingCli(false);
|
||||||
});
|
});
|
||||||
}, [isInstallingCli, loadManagedStatus, managedStatus?.cliShimPath, showSection]);
|
}, [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 handleCopyLogPath = useCallback(() => {
|
||||||
const logPath = managedLogs?.logPath ?? managedStatus?.logPath;
|
const logPath = managedLogs?.logPath ?? managedStatus?.logPath;
|
||||||
if (!logPath) {
|
if (!logPath) {
|
||||||
@@ -340,8 +372,9 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
|||||||
<View style={styles.rowContent}>
|
<View style={styles.rowContent}>
|
||||||
<Text style={styles.rowTitle}>CLI shim</Text>
|
<Text style={styles.rowTitle}>CLI shim</Text>
|
||||||
<Text style={styles.hintText}>
|
<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>
|
</Text>
|
||||||
|
{cliStatusMessage ? <Text style={styles.statusText}>{cliStatusMessage}</Text> : null}
|
||||||
</View>
|
</View>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -475,6 +508,34 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
|||||||
</View>
|
</View>
|
||||||
</AdaptiveModalSheet>
|
</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
|
<AdaptiveModalSheet
|
||||||
visible={isPairingModalOpen}
|
visible={isPairingModalOpen}
|
||||||
onClose={() => setIsPairingModalOpen(false)}
|
onClose={() => setIsPairingModalOpen(false)}
|
||||||
@@ -736,6 +797,17 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||||
lineHeight: 18,
|
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: {
|
input: {
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: theme.colors.border,
|
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 = {
|
export type CliShimResult = {
|
||||||
|
status:
|
||||||
|
| "installed"
|
||||||
|
| "removed"
|
||||||
|
| "elevationDenied"
|
||||||
|
| "automaticInstallUnavailable"
|
||||||
|
| "manualInstallRequired";
|
||||||
installed: boolean;
|
installed: boolean;
|
||||||
path: string | null;
|
path: string | null;
|
||||||
message: string;
|
message: string;
|
||||||
|
manualInstructions: CliManualInstructions | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CliManualInstructions = {
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
commands: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ManagedTcpSettings = {
|
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)) {
|
if (!isRecord(raw)) {
|
||||||
throw new Error("Unexpected CLI shim response.");
|
throw new Error("Unexpected CLI shim response.");
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
status:
|
||||||
|
(toStringOrNull(raw.status) as CliShimResult["status"] | null) ??
|
||||||
|
(raw.installed === true ? "installed" : "removed"),
|
||||||
installed: raw.installed === true,
|
installed: raw.installed === true,
|
||||||
path: toStringOrNull(raw.path),
|
path: toStringOrNull(raw.path),
|
||||||
message: toStringOrNull(raw.message) ?? "",
|
message: toStringOrNull(raw.message) ?? "",
|
||||||
|
manualInstructions: parseCliManualInstructions(raw.manualInstructions),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export function createCli(): Command {
|
|||||||
.option('--label <key=value>', 'Filter by label (can be used multiple times)', collectMultiple, [])
|
.option('--label <key=value>', 'Filter by label (can be used multiple times)', collectMultiple, [])
|
||||||
.option('--thinking <id>', 'Filter by thinking option ID')
|
.option('--thinking <id>', 'Filter by thinking option ID')
|
||||||
.option('--json', 'Output in JSON format')
|
.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))
|
.action(withOutput(runLsCommand))
|
||||||
|
|
||||||
program
|
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('--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('--output-schema <schema>', 'Output JSON matching the provided schema file path or inline JSON schema')
|
||||||
.option('--json', 'Output in JSON format')
|
.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))
|
.action(withOutput(runRunCommand))
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('attach')
|
.command('attach')
|
||||||
.description("Attach to a running agent's output stream")
|
.description("Attach to a running agent's output stream")
|
||||||
.argument('<id>', 'Agent ID (or prefix)')
|
.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)
|
.action(runAttachCommand)
|
||||||
|
|
||||||
program
|
program
|
||||||
@@ -104,7 +104,7 @@ export function createCli(): Command {
|
|||||||
.option('--tail <n>', 'Show last n entries')
|
.option('--tail <n>', 'Show last n entries')
|
||||||
.option('--filter <type>', 'Filter by event type (tools, text, errors, permissions)')
|
.option('--filter <type>', 'Filter by event type (tools, text, errors, permissions)')
|
||||||
.option('--since <time>', 'Show logs since timestamp')
|
.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)
|
.action(runLogsCommand)
|
||||||
|
|
||||||
program
|
program
|
||||||
@@ -114,7 +114,7 @@ export function createCli(): Command {
|
|||||||
.option('--all', 'Stop all agents')
|
.option('--all', 'Stop all agents')
|
||||||
.option('--cwd <path>', 'Stop all agents in directory')
|
.option('--cwd <path>', 'Stop all agents in directory')
|
||||||
.option('--json', 'Output in JSON format')
|
.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))
|
.action(withOutput(runStopCommand))
|
||||||
|
|
||||||
program
|
program
|
||||||
@@ -124,7 +124,7 @@ export function createCli(): Command {
|
|||||||
.option('--all', 'Delete all agents')
|
.option('--all', 'Delete all agents')
|
||||||
.option('--cwd <path>', 'Delete all agents in directory')
|
.option('--cwd <path>', 'Delete all agents in directory')
|
||||||
.option('--json', 'Output in JSON format')
|
.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))
|
.action(withOutput(runDeleteCommand))
|
||||||
|
|
||||||
program
|
program
|
||||||
@@ -135,7 +135,7 @@ export function createCli(): Command {
|
|||||||
.option('--no-wait', 'Return immediately without waiting for completion')
|
.option('--no-wait', 'Return immediately without waiting for completion')
|
||||||
.option('--image <path>', 'Attach image(s) to the message', collectMultiple, [])
|
.option('--image <path>', 'Attach image(s) to the message', collectMultiple, [])
|
||||||
.option('--json', 'Output in JSON format')
|
.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))
|
.action(withOutput(runSendCommand))
|
||||||
|
|
||||||
program
|
program
|
||||||
@@ -143,7 +143,7 @@ export function createCli(): Command {
|
|||||||
.description('Show detailed information about an agent')
|
.description('Show detailed information about an agent')
|
||||||
.argument('<id>', 'Agent ID (or prefix)')
|
.argument('<id>', 'Agent ID (or prefix)')
|
||||||
.option('--json', 'Output in JSON format')
|
.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))
|
.action(withOutput(runInspectCommand))
|
||||||
|
|
||||||
program
|
program
|
||||||
@@ -152,7 +152,7 @@ export function createCli(): Command {
|
|||||||
.argument('<id>', 'Agent ID (or prefix)')
|
.argument('<id>', 'Agent ID (or prefix)')
|
||||||
.option('--timeout <seconds>', 'Maximum wait time (default: no limit)')
|
.option('--timeout <seconds>', 'Maximum wait time (default: no limit)')
|
||||||
.option('--json', 'Output in JSON format')
|
.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))
|
.action(withOutput(runWaitCommand))
|
||||||
|
|
||||||
// Top-level local daemon shortcuts
|
// Top-level local daemon shortcuts
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function createAgentCommand(): Command {
|
|||||||
.option('--label <key=value>', 'Filter by label (can be used multiple times)', collectMultiple, [])
|
.option('--label <key=value>', 'Filter by label (can be used multiple times)', collectMultiple, [])
|
||||||
.option('--thinking <id>', 'Filter by thinking option ID')
|
.option('--thinking <id>', 'Filter by thinking option ID')
|
||||||
.option('--json', 'Output in JSON format')
|
.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))
|
.action(withOutput(runLsCommand))
|
||||||
|
|
||||||
agent
|
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('--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('--output-schema <schema>', 'Output JSON matching the provided schema file path or inline JSON schema')
|
||||||
.option('--json', 'Output in JSON format')
|
.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))
|
.action(withOutput(runRunCommand))
|
||||||
|
|
||||||
agent
|
agent
|
||||||
.command('attach')
|
.command('attach')
|
||||||
.description("Attach to a running agent's output stream")
|
.description("Attach to a running agent's output stream")
|
||||||
.argument('<id>', 'Agent ID (or prefix)')
|
.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)
|
.action(runAttachCommand)
|
||||||
|
|
||||||
agent
|
agent
|
||||||
@@ -64,7 +64,7 @@ export function createAgentCommand(): Command {
|
|||||||
.option('-f, --follow', 'Follow log output (streaming)')
|
.option('-f, --follow', 'Follow log output (streaming)')
|
||||||
.option('--tail <n>', 'Show last n entries')
|
.option('--tail <n>', 'Show last n entries')
|
||||||
.option('--filter <type>', 'Filter by event type (tools, text, errors, permissions)')
|
.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)
|
.action(runLogsCommand)
|
||||||
|
|
||||||
agent
|
agent
|
||||||
@@ -74,7 +74,7 @@ export function createAgentCommand(): Command {
|
|||||||
.option('--all', 'Stop all agents')
|
.option('--all', 'Stop all agents')
|
||||||
.option('--cwd <path>', 'Stop all agents in directory')
|
.option('--cwd <path>', 'Stop all agents in directory')
|
||||||
.option('--json', 'Output in JSON format')
|
.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))
|
.action(withOutput(runStopCommand))
|
||||||
|
|
||||||
agent
|
agent
|
||||||
@@ -84,7 +84,7 @@ export function createAgentCommand(): Command {
|
|||||||
.option('--all', 'Delete all agents')
|
.option('--all', 'Delete all agents')
|
||||||
.option('--cwd <path>', 'Delete all agents in directory')
|
.option('--cwd <path>', 'Delete all agents in directory')
|
||||||
.option('--json', 'Output in JSON format')
|
.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))
|
.action(withOutput(runDeleteCommand))
|
||||||
|
|
||||||
agent
|
agent
|
||||||
@@ -94,7 +94,7 @@ export function createAgentCommand(): Command {
|
|||||||
.argument('<prompt>', 'The message to send')
|
.argument('<prompt>', 'The message to send')
|
||||||
.option('--no-wait', 'Return immediately without waiting for completion')
|
.option('--no-wait', 'Return immediately without waiting for completion')
|
||||||
.option('--json', 'Output in JSON format')
|
.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))
|
.action(withOutput(runSendCommand))
|
||||||
|
|
||||||
agent
|
agent
|
||||||
@@ -102,7 +102,7 @@ export function createAgentCommand(): Command {
|
|||||||
.description('Show detailed information about an agent')
|
.description('Show detailed information about an agent')
|
||||||
.argument('<id>', 'Agent ID (or prefix)')
|
.argument('<id>', 'Agent ID (or prefix)')
|
||||||
.option('--json', 'Output in JSON format')
|
.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))
|
.action(withOutput(runInspectCommand))
|
||||||
|
|
||||||
agent
|
agent
|
||||||
@@ -111,7 +111,7 @@ export function createAgentCommand(): Command {
|
|||||||
.argument('<id>', 'Agent ID (or prefix)')
|
.argument('<id>', 'Agent ID (or prefix)')
|
||||||
.option('--timeout <seconds>', 'Maximum wait time (default: no limit)')
|
.option('--timeout <seconds>', 'Maximum wait time (default: no limit)')
|
||||||
.option('--json', 'Output in JSON format')
|
.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))
|
.action(withOutput(runWaitCommand))
|
||||||
|
|
||||||
// Advanced agent commands (less common operations)
|
// Advanced agent commands (less common operations)
|
||||||
@@ -122,7 +122,7 @@ export function createAgentCommand(): Command {
|
|||||||
.argument('[mode]', 'Mode to set (required unless --list)')
|
.argument('[mode]', 'Mode to set (required unless --list)')
|
||||||
.option('--list', 'List available modes for this agent')
|
.option('--list', 'List available modes for this agent')
|
||||||
.option('--json', 'Output in JSON format')
|
.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))
|
.action(withOutput(runModeCommand))
|
||||||
|
|
||||||
agent
|
agent
|
||||||
@@ -131,7 +131,7 @@ export function createAgentCommand(): Command {
|
|||||||
.argument('<id>', 'Agent ID (or prefix)')
|
.argument('<id>', 'Agent ID (or prefix)')
|
||||||
.option('--force', 'Force archive running agent (interrupts active run first)')
|
.option('--force', 'Force archive running agent (interrupts active run first)')
|
||||||
.option('--json', 'Output in JSON format')
|
.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))
|
.action(withOutput(runArchiveCommand))
|
||||||
|
|
||||||
agent
|
agent
|
||||||
@@ -146,7 +146,7 @@ export function createAgentCommand(): Command {
|
|||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
.option('--json', 'Output in JSON format')
|
.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))
|
.action(withOutput(runUpdateCommand))
|
||||||
|
|
||||||
return agent
|
return agent
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export function createPermitCommand(): Command {
|
|||||||
.command('ls')
|
.command('ls')
|
||||||
.description('List all pending permissions')
|
.description('List all pending permissions')
|
||||||
.option('--json', 'Output in JSON format')
|
.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))
|
.action(withOutput(runLsCommand))
|
||||||
|
|
||||||
permit
|
permit
|
||||||
@@ -22,7 +22,7 @@ export function createPermitCommand(): Command {
|
|||||||
.option('--all', 'Allow all pending permissions for this agent')
|
.option('--all', 'Allow all pending permissions for this agent')
|
||||||
.option('--input <json>', 'Modified input parameters (JSON)')
|
.option('--input <json>', 'Modified input parameters (JSON)')
|
||||||
.option('--json', 'Output in JSON format')
|
.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))
|
.action(withOutput(runAllowCommand))
|
||||||
|
|
||||||
permit
|
permit
|
||||||
@@ -34,7 +34,7 @@ export function createPermitCommand(): Command {
|
|||||||
.option('--message <msg>', 'Denial reason message')
|
.option('--message <msg>', 'Denial reason message')
|
||||||
.option('--interrupt', 'Stop agent after denial')
|
.option('--interrupt', 'Stop agent after denial')
|
||||||
.option('--json', 'Output in JSON format')
|
.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))
|
.action(withOutput(runDenyCommand))
|
||||||
|
|
||||||
return permit
|
return permit
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export function createProviderCommand(): Command {
|
|||||||
.command('ls')
|
.command('ls')
|
||||||
.description('List available providers and status')
|
.description('List available providers and status')
|
||||||
.option('--json', 'Output in JSON format')
|
.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))
|
.action(withOutput(runLsCommand))
|
||||||
|
|
||||||
provider
|
provider
|
||||||
@@ -19,7 +19,7 @@ export function createProviderCommand(): Command {
|
|||||||
.argument('<provider>', 'Provider name (claude, codex, opencode)')
|
.argument('<provider>', 'Provider name (claude, codex, opencode)')
|
||||||
.option('--thinking', 'Include thinking option IDs for each model')
|
.option('--thinking', 'Include thinking option IDs for each model')
|
||||||
.option('--json', 'Output in JSON format')
|
.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))
|
.action(withOutput(runModelsCommand))
|
||||||
|
|
||||||
return provider
|
return provider
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function createSpeechCommand(): Command {
|
|||||||
.command("models")
|
.command("models")
|
||||||
.description("List local speech model download status")
|
.description("List local speech model download status")
|
||||||
.option("--json", "Output in JSON format")
|
.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));
|
.action(withOutput(runSpeechModelsCommand));
|
||||||
|
|
||||||
speech
|
speech
|
||||||
@@ -22,7 +22,7 @@ export function createSpeechCommand(): Command {
|
|||||||
.description("Download local speech models")
|
.description("Download local speech models")
|
||||||
.option("--model <id>", "Model ID to download (repeatable)", collectMultiple, [])
|
.option("--model <id>", "Model ID to download (repeatable)", collectMultiple, [])
|
||||||
.option("--json", "Output in JSON format")
|
.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));
|
.action(withOutput(runSpeechDownloadCommand));
|
||||||
|
|
||||||
return speech;
|
return speech;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export function createWorktreeCommand(): Command {
|
|||||||
.command('ls')
|
.command('ls')
|
||||||
.description('List Paseo-managed git worktrees')
|
.description('List Paseo-managed git worktrees')
|
||||||
.option('--json', 'Output in JSON format')
|
.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))
|
.action(withOutput(runLsCommand))
|
||||||
|
|
||||||
worktree
|
worktree
|
||||||
@@ -18,7 +18,7 @@ export function createWorktreeCommand(): Command {
|
|||||||
.description('Archive a worktree (removes worktree and associated branch)')
|
.description('Archive a worktree (removes worktree and associated branch)')
|
||||||
.argument('<name>', 'Worktree name or branch name')
|
.argument('<name>', 'Worktree name or branch name')
|
||||||
.option('--json', 'Output in JSON format')
|
.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))
|
.action(withOutput(runArchiveCommand))
|
||||||
|
|
||||||
return worktree
|
return worktree
|
||||||
|
|||||||
@@ -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 WebSocket from 'ws'
|
||||||
import { getOrCreateCliClientId } from './client-id.js'
|
import { getOrCreateCliClientId } from './client-id.js'
|
||||||
|
|
||||||
@@ -9,6 +11,7 @@ export interface ConnectOptions {
|
|||||||
|
|
||||||
const DEFAULT_HOST = 'localhost:6767'
|
const DEFAULT_HOST = 'localhost:6767'
|
||||||
const DEFAULT_TIMEOUT = 5000
|
const DEFAULT_TIMEOUT = 5000
|
||||||
|
const PID_FILENAME = 'paseo.pid'
|
||||||
|
|
||||||
type DaemonTarget =
|
type DaemonTarget =
|
||||||
| {
|
| {
|
||||||
@@ -25,7 +28,106 @@ type DaemonTarget =
|
|||||||
* Get the daemon host from environment or options
|
* Get the daemon host from environment or options
|
||||||
*/
|
*/
|
||||||
export function getDaemonHost(options?: ConnectOptions): string {
|
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 {
|
export function resolveDaemonTarget(host: string): DaemonTarget {
|
||||||
@@ -86,50 +188,56 @@ function createNodeWebSocketFactory() {
|
|||||||
* Returns the connected client or throws if connection fails
|
* Returns the connected client or throws if connection fails
|
||||||
*/
|
*/
|
||||||
export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonClient> {
|
export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonClient> {
|
||||||
const host = getDaemonHost(options)
|
|
||||||
const timeout = options?.timeout ?? DEFAULT_TIMEOUT
|
const timeout = options?.timeout ?? DEFAULT_TIMEOUT
|
||||||
const clientId = await getOrCreateCliClientId()
|
const clientId = await getOrCreateCliClientId()
|
||||||
const target = resolveDaemonTarget(host)
|
const hosts = resolveDaemonHostCandidates(options)
|
||||||
const nodeWebSocketFactory = createNodeWebSocketFactory()
|
const nodeWebSocketFactory = createNodeWebSocketFactory()
|
||||||
|
let lastError: unknown = null
|
||||||
|
|
||||||
const client = new DaemonClient(
|
for (const host of hosts) {
|
||||||
{
|
const target = resolveDaemonTarget(host)
|
||||||
url: target.url,
|
const client = new DaemonClient(
|
||||||
clientId,
|
{
|
||||||
clientType: 'cli',
|
url: target.url,
|
||||||
webSocketFactory: (url: string, config?: { headers?: Record<string, string> }) =>
|
clientId,
|
||||||
nodeWebSocketFactory(url, {
|
clientType: 'cli',
|
||||||
headers: config?.headers,
|
webSocketFactory: (url: string, config?: { headers?: Record<string, string> }) =>
|
||||||
...(target.type === 'ipc' ? { socketPath: target.socketPath } : {}),
|
nodeWebSocketFactory(url, {
|
||||||
}),
|
headers: config?.headers,
|
||||||
reconnect: { enabled: false },
|
...(target.type === 'ipc' ? { socketPath: target.socketPath } : {}),
|
||||||
} as unknown as ConstructorParameters<typeof DaemonClient>[0]
|
}),
|
||||||
)
|
reconnect: { enabled: false },
|
||||||
|
} as unknown as ConstructorParameters<typeof DaemonClient>[0]
|
||||||
|
)
|
||||||
|
|
||||||
// Connect with timeout
|
const connectPromise = client.connect()
|
||||||
const connectPromise = client.connect()
|
let timeoutHandle: ReturnType<typeof setTimeout> | null = null
|
||||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = null
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
timeoutHandle = setTimeout(() => {
|
||||||
timeoutHandle = setTimeout(() => {
|
reject(new Error(`Connection timeout after ${timeout}ms`))
|
||||||
reject(new Error(`Connection timeout after ${timeout}ms`))
|
}, timeout)
|
||||||
}, timeout)
|
})
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.race([connectPromise, timeoutPromise])
|
await Promise.race([connectPromise, timeoutPromise])
|
||||||
// Clear the timeout so it doesn't keep the event loop alive
|
if (timeoutHandle) {
|
||||||
if (timeoutHandle) {
|
clearTimeout(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
|
#!/usr/bin/env npx tsx
|
||||||
|
|
||||||
import assert from 'node:assert'
|
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')
|
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('✓ 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 ===')
|
console.log('=== All CLI IPC target tests passed ===')
|
||||||
|
|||||||
@@ -6,21 +6,19 @@ use std::fs;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, Submenu};
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
use tauri::menu::AboutMetadata;
|
use tauri::menu::AboutMetadata;
|
||||||
|
use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, Submenu};
|
||||||
use tauri::{AppHandle, Manager, WebviewWindow};
|
use tauri::{AppHandle, Manager, WebviewWindow};
|
||||||
use tauri_plugin_updater::UpdaterExt;
|
use tauri_plugin_updater::UpdaterExt;
|
||||||
|
|
||||||
mod runtime_manager;
|
mod runtime_manager;
|
||||||
pub use runtime_manager::try_run_cli_shim_from_args;
|
|
||||||
use runtime_manager::{
|
use runtime_manager::{
|
||||||
close_local_daemon_transport, ensure_managed_runtime, install_cli_shim,
|
close_local_daemon_transport, ensure_managed_runtime, install_cli_shim, managed_daemon_logs,
|
||||||
managed_daemon_logs, managed_daemon_pairing, managed_daemon_status, managed_runtime_status,
|
managed_daemon_pairing, managed_daemon_status, managed_runtime_status,
|
||||||
open_local_daemon_transport, restart_managed_daemon,
|
open_local_daemon_transport, restart_managed_daemon, send_local_daemon_transport_message,
|
||||||
send_local_daemon_transport_message, start_managed_daemon, stop_managed_daemon,
|
start_managed_daemon, stop_managed_daemon, uninstall_cli_shim,
|
||||||
uninstall_cli_shim, update_managed_daemon_tcp_settings, ManagedTcpSettings,
|
update_managed_daemon_tcp_settings, LocalTransportState, ManagedTcpSettings,
|
||||||
LocalTransportState,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Store zoom as u64 bits (f64 * 100 as integer for atomic ops)
|
// 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
|
let command = args
|
||||||
.get(flag_index + 1)
|
.get(flag_index + 1)
|
||||||
.ok_or_else(|| "Missing command after --managed-headless".to_string())?;
|
.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() {
|
match command.as_str() {
|
||||||
"runtime-status" => Ok(Some(ManagedHeadlessCommand::RuntimeStatus)),
|
"runtime-status" => Ok(Some(ManagedHeadlessCommand::RuntimeStatus)),
|
||||||
"bootstrap" => Ok(Some(ManagedHeadlessCommand::Bootstrap)),
|
"bootstrap" => Ok(Some(ManagedHeadlessCommand::Bootstrap)),
|
||||||
@@ -134,15 +136,19 @@ fn parse_managed_headless_command() -> Result<Option<ManagedHeadlessCommand>, St
|
|||||||
index += 2;
|
index += 2;
|
||||||
}
|
}
|
||||||
other => {
|
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 {
|
Ok(Some(ManagedHeadlessCommand::UpdateTcp(
|
||||||
enabled,
|
ManagedTcpSettings {
|
||||||
host,
|
enabled,
|
||||||
port,
|
host,
|
||||||
})))
|
port,
|
||||||
|
},
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
other => Err(format!("Unknown --managed-headless command: {other}")),
|
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) => {
|
Err(error) => {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"{}",
|
"{}",
|
||||||
serde_json::to_string_pretty(&json!({ "error": error }))
|
serde_json::to_string_pretty(&json!({ "error": error })).unwrap_or_else(|_| {
|
||||||
.unwrap_or_else(|_| r#"{"error":"managed headless command failed"}"#.to_string())
|
r#"{"error":"managed headless command failed"}"#.to_string()
|
||||||
|
})
|
||||||
);
|
);
|
||||||
app_handle.exit(1);
|
app_handle.exit(1);
|
||||||
}
|
}
|
||||||
@@ -244,7 +251,10 @@ fi"#;
|
|||||||
LocalDaemonVersionResult {
|
LocalDaemonVersionResult {
|
||||||
version: None,
|
version: None,
|
||||||
error: Some(if stderr.is_empty() {
|
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 {
|
} else {
|
||||||
stderr
|
stderr
|
||||||
}),
|
}),
|
||||||
@@ -557,7 +567,8 @@ async fn garbage_collect_attachment_files(
|
|||||||
let entries = fs::read_dir(&attachment_dir)
|
let entries = fs::read_dir(&attachment_dir)
|
||||||
.map_err(|error| format!("Failed to scan attachment directory: {error}"))?;
|
.map_err(|error| format!("Failed to scan attachment directory: {error}"))?;
|
||||||
for entry in entries {
|
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();
|
let path = entry.path();
|
||||||
if !path.is_file() {
|
if !path.is_file() {
|
||||||
continue;
|
continue;
|
||||||
@@ -651,7 +662,8 @@ pub fn run() {
|
|||||||
copyright: app.config().bundle.copyright.clone(),
|
copyright: app.config().bundle.copyright.clone(),
|
||||||
..Default::default()
|
..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() {
|
if submenu.remove_at(0)?.is_some() {
|
||||||
submenu.insert(&about, 0)?;
|
submenu.insert(&about, 0)?;
|
||||||
|
|||||||
@@ -8,9 +8,5 @@
|
|||||||
tauri::embed_plist::embed_info_plist!("../Info.plist");
|
tauri::embed_plist::embed_info_plist!("../Info.plist");
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
if let Err(error) = paseo_lib::try_run_cli_shim_from_args() {
|
|
||||||
eprintln!("{error}");
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
paseo_lib::run();
|
paseo_lib::run();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use std::collections::HashMap;
|
|||||||
use std::env;
|
use std::env;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io;
|
use std::io;
|
||||||
|
use std::iter;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
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 PIPE_PREFIX: &str = r"\\.\pipe\";
|
||||||
const DEFAULT_MANAGED_TCP_HOST: &str = "127.0.0.1";
|
const DEFAULT_MANAGED_TCP_HOST: &str = "127.0.0.1";
|
||||||
const DEFAULT_MANAGED_TCP_PORT: u16 = 7771;
|
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();
|
static RUNTIME_INSTALL_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||||
|
|
||||||
@@ -77,6 +85,7 @@ struct PidFile {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct ManagedPaths {
|
struct ManagedPaths {
|
||||||
runtime_root: PathBuf,
|
runtime_root: PathBuf,
|
||||||
|
stable_runtime_root: PathBuf,
|
||||||
managed_home: PathBuf,
|
managed_home: PathBuf,
|
||||||
transport_path: PathBuf,
|
transport_path: PathBuf,
|
||||||
logs_path: PathBuf,
|
logs_path: PathBuf,
|
||||||
@@ -152,9 +161,19 @@ pub struct ManagedPairingOffer {
|
|||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct CliShimResult {
|
pub struct CliShimResult {
|
||||||
|
pub status: String,
|
||||||
pub installed: bool,
|
pub installed: bool,
|
||||||
pub path: Option<String>,
|
pub path: Option<String>,
|
||||||
pub message: 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)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
@@ -189,17 +208,24 @@ impl Default for LocalTransportState {
|
|||||||
|
|
||||||
impl LocalTransportState {
|
impl LocalTransportState {
|
||||||
fn alloc_session_id(&self) -> String {
|
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)]
|
#[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()
|
url.into_client_request()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[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()
|
url.into_client_request()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,7 +294,10 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(request.uri().to_string(), local_client_url());
|
assert_eq!(request.uri().to_string(), local_client_url());
|
||||||
assert_eq!(
|
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")
|
Some("localhost")
|
||||||
);
|
);
|
||||||
assert!(request.headers().contains_key("sec-websocket-key"));
|
assert!(request.headers().contains_key("sec-websocket-key"));
|
||||||
@@ -280,7 +309,10 @@ mod tests {
|
|||||||
Some("13")
|
Some("13")
|
||||||
);
|
);
|
||||||
assert_eq!(
|
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")
|
Some("websocket")
|
||||||
);
|
);
|
||||||
assert_eq!(
|
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)]
|
#[cfg(unix)]
|
||||||
#[test]
|
#[test]
|
||||||
#[ignore = "requires a running local daemon socket"]
|
#[ignore = "requires a running local daemon socket"]
|
||||||
@@ -301,7 +399,10 @@ mod tests {
|
|||||||
.filter(|value| !value.trim().is_empty())
|
.filter(|value| !value.trim().is_empty())
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
.or_else(|| {
|
.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");
|
.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()))?;
|
.map_err(|error| format!("Failed to inspect {}: {error}", source_path.display()))?;
|
||||||
if file_type.is_symlink() {
|
if file_type.is_symlink() {
|
||||||
let resolved = fs::canonicalize(&source_path).map_err(|error| {
|
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| {
|
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() {
|
if resolved_type.is_dir() {
|
||||||
copy_dir_recursive(&resolved, &target_path)?;
|
copy_dir_recursive(&resolved, &target_path)?;
|
||||||
@@ -390,7 +497,12 @@ fn copy_dir_recursive(source: &Path, target: &Path) -> Result<(), String> {
|
|||||||
.permissions()
|
.permissions()
|
||||||
.mode();
|
.mode();
|
||||||
fs::set_permissions(&target_path, fs::Permissions::from_mode(permissions)).map_err(
|
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));
|
.unwrap_or_else(|| default_transport_path(&managed_home, &test_root));
|
||||||
return Ok(ManagedPaths {
|
return Ok(ManagedPaths {
|
||||||
runtime_root,
|
runtime_root,
|
||||||
|
stable_runtime_root: test_root.join("runtime"),
|
||||||
managed_home: managed_home.clone(),
|
managed_home: managed_home.clone(),
|
||||||
transport_path,
|
transport_path,
|
||||||
logs_path: managed_home.join("daemon.log"),
|
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 root = app_data_root(app)?;
|
||||||
let runtime_root = resolve_override_path("PASEO_DESKTOP_MANAGED_RUNTIME_ROOT")
|
let runtime_root = resolve_override_path("PASEO_DESKTOP_MANAGED_RUNTIME_ROOT")
|
||||||
.unwrap_or_else(|| root.join("runtime").join(runtime_id));
|
.unwrap_or_else(|| root.join("runtime").join(runtime_id));
|
||||||
let managed_home = resolve_override_path("PASEO_DESKTOP_MANAGED_HOME")
|
let managed_home = resolve_override_path("PASEO_DESKTOP_MANAGED_HOME").unwrap_or_else(|| {
|
||||||
.unwrap_or_else(|| {
|
#[cfg(target_os = "macos")]
|
||||||
#[cfg(target_os = "macos")]
|
{
|
||||||
{
|
default_managed_home()
|
||||||
default_managed_home()
|
}
|
||||||
}
|
#[cfg(not(target_os = "macos"))]
|
||||||
#[cfg(not(target_os = "macos"))]
|
{
|
||||||
{
|
default_managed_home(&root)
|
||||||
default_managed_home(&root)
|
}
|
||||||
}
|
});
|
||||||
});
|
|
||||||
let transport_path = resolve_override_path("PASEO_DESKTOP_MANAGED_SOCKET_PATH")
|
let transport_path = resolve_override_path("PASEO_DESKTOP_MANAGED_SOCKET_PATH")
|
||||||
.unwrap_or_else(|| default_transport_path(&managed_home, &root));
|
.unwrap_or_else(|| default_transport_path(&managed_home, &root));
|
||||||
Ok(ManagedPaths {
|
Ok(ManagedPaths {
|
||||||
runtime_root,
|
runtime_root,
|
||||||
|
stable_runtime_root: root.join("runtime"),
|
||||||
managed_home: managed_home.clone(),
|
managed_home: managed_home.clone(),
|
||||||
transport_path,
|
transport_path,
|
||||||
logs_path: managed_home.join("daemon.log"),
|
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())
|
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 root = bundled_runtime_root(app)?;
|
||||||
let pointer_path = root.join("current-runtime.json");
|
let pointer_path = root.join("current-runtime.json");
|
||||||
let pointer = read_json_file::<BundledRuntimePointer>(&pointer_path)?;
|
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
|
let listen = state
|
||||||
.and_then(|value| value.tcp_listen.clone())
|
.and_then(|value| value.tcp_listen.clone())
|
||||||
.unwrap_or_else(|| format!("{DEFAULT_MANAGED_TCP_HOST}:{DEFAULT_MANAGED_TCP_PORT}"));
|
.unwrap_or_else(|| format!("{DEFAULT_MANAGED_TCP_HOST}:{DEFAULT_MANAGED_TCP_PORT}"));
|
||||||
let (host, port) =
|
let (host, port) = parse_tcp_listen(&listen).unwrap_or((
|
||||||
parse_tcp_listen(&listen).unwrap_or((DEFAULT_MANAGED_TCP_HOST.to_string(), DEFAULT_MANAGED_TCP_PORT));
|
DEFAULT_MANAGED_TCP_HOST.to_string(),
|
||||||
|
DEFAULT_MANAGED_TCP_PORT,
|
||||||
|
));
|
||||||
ManagedTcpSettings {
|
ManagedTcpSettings {
|
||||||
enabled: state.map(|value| value.tcp_enabled).unwrap_or(false),
|
enabled: state.map(|value| value.tcp_enabled).unwrap_or(false),
|
||||||
host,
|
host,
|
||||||
@@ -681,10 +798,11 @@ fn cli_host_for_target(target: &ManagedTransportTarget) -> String {
|
|||||||
|
|
||||||
fn cli_env(paths: &ManagedPaths) -> Vec<(String, String)> {
|
fn cli_env(paths: &ManagedPaths) -> Vec<(String, String)> {
|
||||||
let state = read_state_file(&paths.state_file_path);
|
let state = read_state_file(&paths.state_file_path);
|
||||||
let target = managed_transport_target(paths, state.as_ref()).unwrap_or(ManagedTransportTarget {
|
let target =
|
||||||
transport_type: default_transport_type().to_string(),
|
managed_transport_target(paths, state.as_ref()).unwrap_or(ManagedTransportTarget {
|
||||||
transport_path: paths.transport_path.to_string_lossy().into_owned(),
|
transport_type: default_transport_type().to_string(),
|
||||||
});
|
transport_path: paths.transport_path.to_string_lossy().into_owned(),
|
||||||
|
});
|
||||||
vec![
|
vec![
|
||||||
(
|
(
|
||||||
"PASEO_HOME".to_string(),
|
"PASEO_HOME".to_string(),
|
||||||
@@ -703,10 +821,16 @@ fn cli_command(
|
|||||||
let node = runtime_root.join(&manifest.node_relative_path);
|
let node = runtime_root.join(&manifest.node_relative_path);
|
||||||
let cli = runtime_root.join(&manifest.cli_entrypoint_relative_path);
|
let cli = runtime_root.join(&manifest.cli_entrypoint_relative_path);
|
||||||
if !node.exists() {
|
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() {
|
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);
|
let mut command = Command::new(node);
|
||||||
command.arg(cli);
|
command.arg(cli);
|
||||||
@@ -717,6 +841,177 @@ fn cli_command(
|
|||||||
Ok(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> {
|
fn read_pid_from_home(managed_home: &Path) -> Option<i64> {
|
||||||
read_json_file::<PidFile>(&managed_home.join("paseo.pid"))
|
read_json_file::<PidFile>(&managed_home.join("paseo.pid"))
|
||||||
.ok()
|
.ok()
|
||||||
@@ -752,8 +1047,12 @@ fn install_runtime_if_needed(source_root: &Path, target_root: &Path) -> Result<(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if target_root.exists() {
|
if target_root.exists() {
|
||||||
fs::remove_dir_all(target_root)
|
fs::remove_dir_all(target_root).map_err(|error| {
|
||||||
.map_err(|error| format!("Failed to remove incomplete runtime {}: {error}", target_root.display()))?;
|
format!(
|
||||||
|
"Failed to remove incomplete runtime {}: {error}",
|
||||||
|
target_root.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let staging_root = target_root.with_extension(format!("installing-{}", std::process::id()));
|
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)
|
fs::create_dir_all(&paths.managed_home)
|
||||||
.map_err(|error| format!("Failed to create {}: {error}", paths.managed_home.display()))?;
|
.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 existing_state = read_state_file(&paths.state_file_path);
|
||||||
let target = managed_transport_target(&paths, existing_state.as_ref())?;
|
let target = managed_transport_target(&paths, existing_state.as_ref())?;
|
||||||
let state = ManagedStateFile {
|
let state = ManagedStateFile {
|
||||||
@@ -812,7 +1112,9 @@ fn ensure_runtime_installed_internal(app: &AppHandle) -> Result<ManagedRuntimeSt
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|entry| entry.tcp_enabled)
|
.map(|entry| entry.tcp_enabled)
|
||||||
.unwrap_or(false),
|
.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),
|
cli_shim_path: existing_state.and_then(|entry| entry.cli_shim_path),
|
||||||
};
|
};
|
||||||
write_state_file(&paths.state_file_path, &state)?;
|
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(
|
let cli_status = run_cli_json_command(
|
||||||
&paths.runtime_root,
|
&paths.runtime_root,
|
||||||
&manifest,
|
&manifest,
|
||||||
&["daemon", "status", "--home", &paths.managed_home.to_string_lossy(), "--json"],
|
&[
|
||||||
|
"daemon",
|
||||||
|
"status",
|
||||||
|
"--home",
|
||||||
|
&paths.managed_home.to_string_lossy(),
|
||||||
|
"--json",
|
||||||
|
],
|
||||||
&paths,
|
&paths,
|
||||||
)
|
)
|
||||||
.ok();
|
.ok();
|
||||||
@@ -915,93 +1223,163 @@ fn managed_daemon_status_internal(app: &AppHandle) -> Result<ManagedDaemonStatus
|
|||||||
tcp_listen: tcp_settings
|
tcp_listen: tcp_settings
|
||||||
.enabled
|
.enabled
|
||||||
.then(|| format!("{}:{}", tcp_settings.host, tcp_settings.port)),
|
.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 {
|
#[cfg(target_os = "macos")]
|
||||||
format!(
|
fn install_cli_shim_via_macos_prompt(shim_path: &Path, contents: &str) -> Result<(), String> {
|
||||||
"#!/usr/bin/env sh\nexec \"{}\" --paseo-cli-shim \"{}\" \"$@\"\n",
|
// Manual smoke path: click Install CLI in the desktop app on macOS and confirm the
|
||||||
app_exe.display(),
|
// /usr/local/bin/paseo prompt writes the trivial outer shim shown in cli_manual_instructions().
|
||||||
state_file.display()
|
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> {
|
fn install_cli_shim_internal(app: &AppHandle) -> Result<CliShimResult, String> {
|
||||||
let status = ensure_runtime_installed_internal(app)?;
|
let status = ensure_runtime_installed_internal(app)?;
|
||||||
let paths = resolve_paths(app, &status.runtime_id)?;
|
let paths = resolve_paths(app, &status.runtime_id)?;
|
||||||
let bin_dir = if cfg!(target_os = "windows") {
|
let manifest = load_runtime_manifest(&paths.runtime_root)?;
|
||||||
dirs::data_dir()
|
let inner_launcher = ensure_inner_cli_launcher(&paths, &manifest)?;
|
||||||
.ok_or_else(|| "Failed to resolve data directory for CLI shim.".to_string())?
|
let shim_path = outer_cli_shim_path()?;
|
||||||
.join("paseo")
|
let shim_contents = outer_cli_shim_contents(&inner_launcher);
|
||||||
.join("bin")
|
let manual_instructions = cli_manual_instructions(&paths, &manifest)?;
|
||||||
} else {
|
|
||||||
dirs::home_dir()
|
#[cfg(target_os = "macos")]
|
||||||
.ok_or_else(|| "Failed to resolve home directory for CLI shim.".to_string())?
|
let install_result = install_cli_shim_via_macos_prompt(&shim_path, &shim_contents);
|
||||||
.join(".local")
|
#[cfg(all(not(target_os = "macos"), not(windows)))]
|
||||||
.join("bin")
|
let install_result = write_cli_launcher(&shim_path, &shim_contents);
|
||||||
};
|
#[cfg(windows)]
|
||||||
fs::create_dir_all(&bin_dir)
|
let install_result: Result<(), String> = Err("AUTOMATIC_INSTALL_UNAVAILABLE".to_string());
|
||||||
.map_err(|error| format!("Failed to create {}: {error}", bin_dir.display()))?;
|
|
||||||
let shim_path = if cfg!(target_os = "windows") {
|
match install_result {
|
||||||
bin_dir.join("paseo.cmd")
|
Ok(()) => {
|
||||||
} else {
|
let mut state = read_state_file(&paths.state_file_path).unwrap_or(ManagedStateFile {
|
||||||
bin_dir.join("paseo")
|
runtime_id: status.runtime_id.clone(),
|
||||||
};
|
runtime_root: paths.runtime_root.to_string_lossy().into_owned(),
|
||||||
let app_exe = std::env::current_exe()
|
managed_home: paths.managed_home.to_string_lossy().into_owned(),
|
||||||
.map_err(|error| format!("Failed to resolve desktop executable: {error}"))?;
|
transport_type: status.transport_type.clone(),
|
||||||
if cfg!(target_os = "windows") {
|
transport_path: status.transport_path.clone(),
|
||||||
fs::write(
|
tcp_enabled: false,
|
||||||
&shim_path,
|
tcp_listen: None,
|
||||||
format!(
|
cli_shim_path: None,
|
||||||
"@echo off\r\n\"{}\" --paseo-cli-shim \"{}\" %*\r\n",
|
});
|
||||||
app_exe.display(),
|
state.cli_shim_path = Some(shim_path.to_string_lossy().into_owned());
|
||||||
paths.state_file_path.display()
|
write_state_file(&paths.state_file_path, &state)?;
|
||||||
),
|
Ok(CliShimResult {
|
||||||
)
|
status: "installed".to_string(),
|
||||||
.map_err(|error| format!("Failed to write {}: {error}", shim_path.display()))?;
|
installed: true,
|
||||||
} else {
|
path: Some(shim_path.to_string_lossy().into_owned()),
|
||||||
fs::write(&shim_path, cli_shim_contents(&app_exe, &paths.state_file_path))
|
message: format!("Paseo CLI installed at {}.", shim_path.display()),
|
||||||
.map_err(|error| format!("Failed to write {}: {error}", shim_path.display()))?;
|
manual_instructions: None,
|
||||||
#[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()),
|
|
||||||
)?;
|
|
||||||
}
|
}
|
||||||
|
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> {
|
fn uninstall_cli_shim_internal(app: &AppHandle) -> Result<CliShimResult, String> {
|
||||||
let status = ensure_runtime_installed_internal(app)?;
|
let status = ensure_runtime_installed_internal(app)?;
|
||||||
let paths = resolve_paths(app, &status.runtime_id)?;
|
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);
|
let shim_path = detect_installed_cli_shim_path(&paths)
|
||||||
if let Some(shim_path) = shim_path.clone() {
|
.map(PathBuf::from)
|
||||||
let shim_path_buf = PathBuf::from(&shim_path);
|
.or_else(|| {
|
||||||
if shim_path_buf.exists() {
|
read_state_file(&paths.state_file_path)
|
||||||
fs::remove_file(&shim_path_buf)
|
.and_then(|entry| entry.cli_shim_path.map(PathBuf::from))
|
||||||
.map_err(|error| format!("Failed to remove {}: {error}", shim_path_buf.display()))?;
|
})
|
||||||
}
|
.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 {
|
let mut state = read_state_file(&paths.state_file_path).unwrap_or(ManagedStateFile {
|
||||||
runtime_id: status.runtime_id.clone(),
|
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)?;
|
write_state_file(&paths.state_file_path, &state)?;
|
||||||
|
|
||||||
Ok(CliShimResult {
|
Ok(CliShimResult {
|
||||||
|
status: "removed".to_string(),
|
||||||
installed: false,
|
installed: false,
|
||||||
path: None,
|
path: Some(shim_path.to_string_lossy().into_owned()),
|
||||||
message: "CLI shim removed.".to_string(),
|
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> {
|
fn start_managed_daemon_internal(app: &AppHandle) -> Result<ManagedDaemonStatus, String> {
|
||||||
let status = ensure_runtime_installed_internal(app)?;
|
let status = ensure_runtime_installed_internal(app)?;
|
||||||
let paths = resolve_paths(app, &status.runtime_id)?;
|
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(
|
let value = run_cli_json_command(
|
||||||
&paths.runtime_root,
|
&paths.runtime_root,
|
||||||
&manifest,
|
&manifest,
|
||||||
&["daemon", "pair", "--home", &paths.managed_home.to_string_lossy(), "--json"],
|
&[
|
||||||
|
"daemon",
|
||||||
|
"pair",
|
||||||
|
"--home",
|
||||||
|
&paths.managed_home.to_string_lossy(),
|
||||||
|
"--json",
|
||||||
|
],
|
||||||
&paths,
|
&paths,
|
||||||
)?;
|
)?;
|
||||||
serde_json::from_value::<ManagedPairingOffer>(value)
|
serde_json::from_value::<ManagedPairingOffer>(value)
|
||||||
@@ -1294,9 +1646,11 @@ pub async fn update_managed_daemon_tcp_settings(
|
|||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
settings: ManagedTcpSettings,
|
settings: ManagedTcpSettings,
|
||||||
) -> Result<ManagedDaemonStatus, String> {
|
) -> Result<ManagedDaemonStatus, String> {
|
||||||
tauri::async_runtime::spawn_blocking(move || update_managed_tcp_settings_internal(&app, settings))
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
.await
|
update_managed_tcp_settings_internal(&app, settings)
|
||||||
.map_err(|error| format!("Managed daemon TCP settings task failed: {error}"))?
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("Managed daemon TCP settings task failed: {error}"))?
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn spawn_local_transport_session<S>(
|
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))
|
let ws_stream = connect_local_socket(PathBuf::from(transport_path))
|
||||||
.await
|
.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
|
spawn_local_transport_session(app, transport_state, session_id, ws_stream).await
|
||||||
}
|
}
|
||||||
#[cfg(not(unix))]
|
#[cfg(not(unix))]
|
||||||
|
|||||||
Reference in New Issue
Block a user