refactor: streamline host and Docker agent setup UI

Simplifies the onboarding flow by removing verbose instructions and toggles, consolidating navigation elements, and cleaning up the settings interface. Improves the macOS host agent installer with better Keychain access control and launchd service management.
This commit is contained in:
rcourtman 2025-10-24 14:59:50 +00:00
parent 8f553a93e9
commit 655fec2225
9 changed files with 424 additions and 468 deletions

View file

@ -404,9 +404,33 @@ elif [[ "$PLATFORM" == "darwin" ]] && command -v launchctl &> /dev/null; then
security delete-generic-password -s "pulse-host-agent" -a "$USER" 2>/dev/null || true
# Add token to Keychain
if security add-generic-password -s "pulse-host-agent" -a "$USER" -w "$PULSE_TOKEN" -U 2>/dev/null; then
log_success "Token stored securely in macOS Keychain"
USE_KEYCHAIN=true
KEYCHAIN_SERVICE="pulse-host-agent"
KEYCHAIN_ACCOUNT="$USER"
KEYCHAIN_APPS=(
"/usr/local/bin/pulse-host-agent"
"/usr/local/bin/pulse-host-agent-wrapper.sh"
"/usr/bin/security"
)
KEYCHAIN_ARGS=()
for app in "${KEYCHAIN_APPS[@]}"; do
KEYCHAIN_ARGS+=(-T "$app")
done
if security add-generic-password \
-s "$KEYCHAIN_SERVICE" \
-a "$KEYCHAIN_ACCOUNT" \
-w "$PULSE_TOKEN" \
-U \
"${KEYCHAIN_ARGS[@]}" 2>/dev/null; then
if security find-generic-password -s "$KEYCHAIN_SERVICE" -a "$KEYCHAIN_ACCOUNT" -w >/dev/null 2>&1; then
log_success "Token stored securely in macOS Keychain"
USE_KEYCHAIN=true
else
log_warn "Token saved but Keychain denied non-interactive read access"
log_info "Will fall back to embedding token in the launchd plist"
USE_KEYCHAIN=false
fi
else
log_warn "Failed to store token in Keychain, will use plist instead"
log_info "You may need to grant Keychain access permissions"
@ -419,14 +443,21 @@ elif [[ "$PLATFORM" == "darwin" ]] && command -v launchctl &> /dev/null; then
# Create wrapper script if using Keychain
if [[ "$USE_KEYCHAIN" == true ]]; then
WRAPPER_SCRIPT="/usr/local/bin/pulse-host-agent-wrapper.sh"
TMP_WRAPPER=$(mktemp)
cat > "$WRAPPER_SCRIPT" <<'WRAPPER_EOF'
cat > "$TMP_WRAPPER" <<'WRAPPER_EOF'
#!/bin/bash
# Pulse Host Agent Wrapper - Reads token from Keychain
set -e
set -u
LOG_FILE="$HOME/Library/Logs/Pulse/host-agent-wrapper.log"
mkdir -p "$(dirname "$LOG_FILE")"
# Read token from Keychain
PULSE_TOKEN=$(security find-generic-password -s "pulse-host-agent" -w 2>/dev/null || echo "")
if ! PULSE_TOKEN=$(security find-generic-password -s "pulse-host-agent" -a "$USER" -w 2>/dev/null); then
echo "$(date -Is) pulse-host-agent-wrapper: failed to read token from Keychain" >>"$LOG_FILE"
PULSE_TOKEN=""
fi
# Export for agent to use
export PULSE_TOKEN
@ -435,7 +466,23 @@ export PULSE_TOKEN
exec /usr/local/bin/pulse-host-agent "$@"
WRAPPER_EOF
chmod +x "$WRAPPER_SCRIPT"
if ! sudo mv "$TMP_WRAPPER" "$WRAPPER_SCRIPT"; then
if ! mv "$TMP_WRAPPER" "$WRAPPER_SCRIPT" 2>/dev/null; then
rm -f "$TMP_WRAPPER"
log_error "Failed to write Keychain wrapper to $WRAPPER_SCRIPT. Try re-running with sudo."
exit 1
fi
fi
if ! sudo chmod 755 "$WRAPPER_SCRIPT" 2>/dev/null && ! chmod 755 "$WRAPPER_SCRIPT" 2>/dev/null; then
log_error "Failed to set execute permissions on $WRAPPER_SCRIPT."
exit 1
fi
if command -v chown &>/dev/null; then
sudo chown root:wheel "$WRAPPER_SCRIPT" 2>/dev/null || sudo chown root:root "$WRAPPER_SCRIPT" 2>/dev/null || true
fi
log_success "Created Keychain wrapper script"
# Create plist using wrapper (token not in plist!)
@ -502,8 +549,23 @@ EOF
# Set restrictive permissions on plist
chmod 600 "$LAUNCHD_PLIST"
launchctl load "$LAUNCHD_PLIST"
log_success "Launchd service enabled and started"
LAUNCH_TARGET="gui/$(id -u)"
# Attempt to unload any existing service instance
if launchctl bootout "$LAUNCH_TARGET" "$LAUNCHD_PLIST" 2>/dev/null; then
log_info "Replaced existing launchd service definition"
fi
if launchctl bootstrap "$LAUNCH_TARGET" "$LAUNCHD_PLIST"; then
launchctl enable "$LAUNCH_TARGET/com.pulse.host-agent" 2>/dev/null || true
launchctl kickstart -k "$LAUNCH_TARGET/com.pulse.host-agent" 2>/dev/null || true
log_success "Launchd service enabled and started"
else
log_error "Failed to load launchd service. Try running:"
echo " launchctl bootstrap $LAUNCH_TARGET $LAUNCHD_PLIST"
echo " launchctl kickstart -k $LAUNCH_TARGET/com.pulse.host-agent"
exit 1
fi
else
log_warn "Automatic service setup not available for this platform"
log_info "To run the agent manually:"

View file

@ -999,18 +999,6 @@ function AppLayout(props: {
>
{platform.icon}
<span>{platform.label}</span>
<Show when={!platform.live}>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
navigate(platform.settingsRoute);
}}
class="ml-1 text-[10px] uppercase tracking-wide text-gray-400 dark:text-gray-600 hover:text-blue-500 focus-visible:outline-none focus-visible:ring-0"
>
Add host
</button>
</Show>
</div>
);
}}

View file

@ -663,19 +663,9 @@ export const DockerHosts: Component<DockerHostsProps> = (props) => {
{/* Left: Host List - Only show if more than 1 host */}
<Show when={sortedHosts().length > 1}>
<Card padding="none" class="w-72 flex-shrink-0 overflow-hidden">
<div class="bg-gray-50 dark:bg-gray-800 px-4 py-2 border-b border-gray-200 dark:border-gray-700 flex items-start justify-between gap-2">
<div>
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Docker Hosts</h3>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{sortedHosts().length} {sortedHosts().length === 1 ? 'host' : 'hosts'}</p>
</div>
<button
type="button"
onClick={() => navigate('/settings/docker')}
class="inline-flex items-center gap-1 rounded border border-blue-200 dark:border-blue-500/40 bg-white dark:bg-blue-500/10 px-2 py-1 text-[11px] font-medium text-blue-600 dark:text-blue-300 shadow-sm hover:bg-blue-50 dark:hover:bg-blue-500/20 transition-colors"
>
<span class="flex items-center justify-center">+</span>
<span>Add Host</span>
</button>
<div class="bg-gray-50 dark:bg-gray-800 px-4 py-2 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Docker Hosts</h3>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{sortedHosts().length} {sortedHosts().length === 1 ? 'host' : 'hosts'}</p>
</div>
<div class="divide-y divide-gray-200 dark:divide-gray-700">
<For each={sortedHosts()}>
@ -1054,29 +1044,6 @@ export const DockerHosts: Component<DockerHostsProps> = (props) => {
Deploy the Pulse Docker agent on at least one Docker host to light up this tab. As soon as an agent reports in, container metrics appear automatically.
</span>
}
actions={
<>
<CopyButton
text={`docker run -d \ \
--name pulse-docker-agent \ \
-e PULSE_URL="http://<pulse-server>:8080" \ \
-e PULSE_TOKEN="<your-api-token>" \ \
-v /var/run/docker.sock:/var/run/docker.sock \ \
ghcr.io/rcourtman/pulse-docker-agent:latest`}
class="w-full sm:w-auto"
>
Copy install command
</CopyButton>
<a
href="https://github.com/rcourtman/Pulse/blob/main/docs/DOCKER_MONITORING.md"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
Read the Docker monitoring guide
</a>
</>
}
/>
</Card>
</Show>

View file

@ -12,7 +12,6 @@ import { DOCKER_REPORT_SCOPE } from '@/constants/apiScopes';
export const DockerAgents: Component = () => {
const { state } = useWebSocket();
const [showInstructions, setShowInstructions] = createSignal(true);
let hasLoggedSecurityStatusError = false;
@ -367,22 +366,6 @@ WantedBy=multi-user.target`;
return (
<div class="space-y-8">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Setup & Management</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-0.5">Deploy agents or manage existing Docker hosts</p>
</div>
<button
type="button"
onClick={() => setShowInstructions(!showInstructions())}
class="px-4 py-2 text-sm font-medium text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/30 rounded-lg hover:bg-blue-100 dark:hover:bg-blue-900/50 transition-colors"
>
{showInstructions() ? 'Hide' : 'Show'} deployment instructions
</button>
</div>
{/* Deployment Instructions */}
<Show when={showInstructions()}>
<Card class="space-y-5">
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Add a Docker host</h3>
@ -391,26 +374,6 @@ WantedBy=multi-user.target`;
</p>
</div>
<details class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-700 dark:border-gray-700 dark:bg-gray-900/40 dark:text-gray-300">
<summary class="flex cursor-pointer items-center justify-between font-semibold text-gray-800 dark:text-gray-100">
<span>What exactly gets installed?</span>
<svg class="h-4 w-4 text-gray-500 transition-transform group-open:-rotate-180" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div class="mt-3 space-y-2">
<ul class="list-disc space-y-1 pl-5 leading-snug">
<li>A single self-contained Go binary (<code class="font-mono text-[11px]">pulse-docker-agent</code>, ~7&nbsp;MB)</li>
<li>A systemd unit on Linux or Unraid startup script so the agent restarts after reboots</li>
<li>No extra dependencies: it talks directly to <code class="font-mono text-[11px]">/var/run/docker.sock</code> and sends metrics over HTTPS</li>
<li>Every report includes a control handshake so Pulse can issue constrained commands (e.g. stop) without running arbitrary shell</li>
</ul>
<p class="text-xs text-gray-500 dark:text-gray-400">
Removing a host tears down the service and autostart hook automatically; keeping the binary for quick reinstalls is optional and called out in the dialog.
</p>
</div>
</details>
<Show when={requiresToken()}>
<div class="space-y-3">
<div class="space-y-1">
@ -573,7 +536,6 @@ WantedBy=multi-user.target`;
</div>
</details>
</Card>
</Show>
{/* Remove Docker Host Modal */}
<Show when={showRemoveModal()}>

View file

@ -1,55 +1,21 @@
import { type Component, For, Show, createEffect, createMemo, createSignal, onMount } from 'solid-js';
import { type Component, For, Show, createEffect, createMemo, createSignal, on, onMount } from 'solid-js';
import type { JSX } from 'solid-js';
import { useWebSocket } from '@/App';
import type { Host } from '@/types/api';
import { Card } from '@/components/shared/Card';
import CopyButton from '@/components/shared/CopyButton';
import { formatBytes, formatRelativeTime, formatUptime } from '@/utils/format';
import { notificationStore } from '@/stores/notifications';
import { showTokenReveal } from '@/stores/tokenReveal';
import { HOST_AGENT_SCOPE } from '@/constants/apiScopes';
import type { SecurityStatus } from '@/types/config';
import type { APITokenRecord } from '@/api/security';
import { useScopedTokenManager } from '@/hooks/useScopedTokenManager';
import SquareTerminal from 'lucide-solid/icons/square-terminal';
import { SecurityAPI } from '@/api/security';
type HostAgentVariant = 'all' | 'linux' | 'macos' | 'windows';
type HostAgentVariant = 'linux' | 'macos' | 'windows';
interface HostAgentsProps {
variant?: HostAgentVariant;
}
type HostPlatform = 'linux' | 'macos' | 'windows';
const hostPlatformOptions: { id: HostPlatform; label: string; description: string; icon: typeof SquareTerminal | JSX.Element }[] = [
{
id: 'linux',
label: 'Linux',
description: 'Download the static binary and enable the systemd service on Debian, Ubuntu, RHEL, Arch, and more.',
icon: SquareTerminal,
},
{
id: 'macos',
label: 'macOS',
description: 'Use the universal binary with launchd to keep desktops and hosts reporting in the background.',
icon: (
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M14.94 5.19A4.38 4.38 0 0 0 16 2a4.44 4.44 0 0 0-3 1.52 4.17 4.17 0 0 0-1 3.09 3.69 3.69 0 0 0 2.94-1.42zm2.52 7.44a4.51 4.51 0 0 1 2.16-3.81 4.66 4.66 0 0 0-3.66-2c-1.56-.16-3 .91-3.83.91s-2-.89-3.3-.87A4.92 4.92 0 0 0 4.69 9.39C2.93 12.45 4.24 17 6 19.47c.8 1.21 1.8 2.58 3.12 2.53s1.75-.82 3.28-.82 2 .82 3.3.79 2.22-1.24 3.06-2.45a11 11 0 0 0 1.38-2.85 4.41 4.41 0 0 1-2.68-4.08z" />
</svg>
),
},
{
id: 'windows',
label: 'Windows',
description: 'Native Windows service with automatic startup. PowerShell script handles binary download and service installation.',
icon: (
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M0 3.449L9.75 2.1v9.451H0m10.949-9.602L24 0v11.4H10.949M0 12.6h9.75v9.451L0 20.699M10.949 12.6H24V24l-12.9-1.801" />
</svg>
),
},
];
const TOKEN_PLACEHOLDER = '<api-token>';
const pulseUrl = () => {
if (typeof window === 'undefined') return 'http://localhost:7655';
@ -57,23 +23,14 @@ const pulseUrl = () => {
return `${protocol}//${hostname}${port ? `:${port}` : ''}`;
};
const buildDefaultTokenName = () => {
const now = new Date();
const iso = now.toISOString().slice(0, 16); // YYYY-MM-DDTHH:MM
const stamp = iso.replace('T', ' ').replace(/:/g, '-');
return `Host agent ${stamp}`;
};
const commandsByVariant: Record<HostAgentVariant, { title: string; description: string; snippets: { label: string; command: string; note?: string | JSX.Element }[] }> = {
all: {
title: 'Installation',
description:
'Run the installer script to automatically download and configure the host agent on any supported platform.',
snippets: [
{
label: 'Install host agent',
command: `curl -fsSL ${pulseUrl()}/install-host-agent.sh | bash -s -- --url ${pulseUrl()} --token ${TOKEN_PLACEHOLDER} --interval 30s`,
note: (
<span>
The script downloads the agent binary from Pulse and sets up systemd (Linux) or launchd (macOS) for automatic startup.
</span>
),
},
],
},
linux: {
title: 'Install on Linux',
description:
@ -133,49 +90,51 @@ const commandsByVariant: Record<HostAgentVariant, { title: string; description:
},
};
const platformFilters: Record<HostAgentVariant, string[] | null> = {
all: null,
const platformFilters: Record<HostAgentVariant, string[]> = {
linux: ['linux'],
macos: ['macos'],
windows: ['windows'],
};
export const HostAgents: Component<HostAgentsProps> = (props) => {
const variant: HostAgentVariant = props.variant ?? 'all';
const variant = () => props.variant ?? 'linux';
const { state } = useWebSocket();
let hasLoggedSecurityStatusError = false;
const [showInstructions, setShowInstructions] = createSignal(true);
const [securityStatus, setSecurityStatus] = createSignal<SecurityStatus | null>(null);
const [showGenerateTokenModal, setShowGenerateTokenModal] = createSignal(false);
const [newTokenName, setNewTokenName] = createSignal('');
const [generateError, setGenerateError] = createSignal<string | null>(null);
const [latestRecord, setLatestRecord] = createSignal<APITokenRecord | null>(null);
const [stepTwoComplete, setStepTwoComplete] = createSignal(false);
const {
token: apiToken,
setToken: setApiToken,
isGeneratingToken,
generateToken,
} = useScopedTokenManager({
scope: HOST_AGENT_SCOPE,
storageKey: 'hostAgentToken',
legacyKeys: ['apiToken'],
});
const [tokenName, setTokenName] = createSignal('');
const [confirmedNoToken, setConfirmedNoToken] = createSignal(false);
const [currentToken, setCurrentToken] = createSignal<string | null>(null);
const [isGeneratingToken, setIsGeneratingToken] = createSignal(false);
createEffect(() => {
if (!apiToken()) {
setStepTwoComplete(false);
if (requiresToken()) {
setConfirmedNoToken(false);
} else {
setCurrentToken(null);
setLatestRecord(null);
}
});
const hosts = createMemo(() => {
createEffect(
on(
variant,
() => {
setLatestRecord(null);
setCurrentToken(null);
setConfirmedNoToken(false);
setTokenName('');
},
{ defer: true },
),
);
const allHosts = createMemo(() => {
const list = state.hosts ?? [];
const filters = platformFilters[variant];
const filtered = filters ? list.filter((host) => filters.includes((host.platform ?? '').toLowerCase())) : list;
return [...filtered].sort((a, b) => (a.hostname || '').localeCompare(b.hostname || ''));
return [...list].sort((a, b) => (a.hostname || '').localeCompare(b.hostname || ''));
});
const renderTags = (host: Host) => {
@ -184,15 +143,7 @@ export const HostAgents: Component<HostAgentsProps> = (props) => {
return tags.join(', ');
};
const [selectedPlatform, setSelectedPlatform] = createSignal<HostPlatform>('linux');
const effectiveVariant = createMemo<HostAgentVariant>(() =>
variant === 'all' ? selectedPlatform() : variant,
);
const installMeta = createMemo(() => commandsByVariant[effectiveVariant()]);
const tokenStepLabel = () => `${variant === 'all' ? 'Step 2' : 'Step 1'} · Choose an API token`;
const commandStepLabel = () => `${variant === 'all' ? 'Step 3' : 'Step 2'} · Installation commands`;
const installMeta = createMemo(() => commandsByVariant[variant()]);
onMount(() => {
if (typeof window === 'undefined') {
@ -225,52 +176,38 @@ export const HostAgents: Component<HostAgentsProps> = (props) => {
return true;
};
const tokenReady = () => !requiresToken() || Boolean(apiToken());
const commandsUnlocked = () => tokenReady() && stepTwoComplete();
const hasToken = () => Boolean(currentToken());
const commandsUnlocked = () => (requiresToken() ? hasToken() : hasToken() || confirmedNoToken());
const acknowledgeTokenUse = () => {
if (!requiresToken()) {
setStepTwoComplete(true);
const acknowledgeNoToken = () => {
if (requiresToken()) {
notificationStore.info('Generate or select a token before continuing.', 4000);
return;
}
if (apiToken()) {
setStepTwoComplete(true);
notificationStore.success('Token ready to embed in the install commands.', 3500);
} else {
notificationStore.info('Generate or select a token before continuing.', 4000);
}
setCurrentToken(null);
setLatestRecord(null);
setConfirmedNoToken(true);
notificationStore.success('Confirmed install commands without an API token.', 3500);
};
const openGenerateTokenModal = () => {
setGenerateError(null);
const defaultName = `Host agent ${new Date().toISOString().slice(0, 10)}`;
setNewTokenName(defaultName);
setShowGenerateTokenModal(true);
setStepTwoComplete(false);
};
const handleCreateToken = async () => {
const handleGenerateToken = async () => {
if (isGeneratingToken()) return;
setGenerateError(null);
setIsGeneratingToken(true);
try {
const desiredName = newTokenName().trim() || `Host agent ${new Date().toISOString().slice(0, 10)}`;
const { token, record } = await generateToken(desiredName);
const desiredName = tokenName().trim() || buildDefaultTokenName();
const { token, record } = await SecurityAPI.createToken(desiredName, [HOST_AGENT_SCOPE]);
setShowGenerateTokenModal(false);
setNewTokenName('');
setCurrentToken(token);
setLatestRecord(record);
showTokenReveal({
token,
record,
source: 'host-agent',
note: `Copy this token into the host agent install command. Scope: ${HOST_AGENT_SCOPE}.`,
});
notificationStore.success('Created host agent API token with reporting scope.', 6000);
setTokenName('');
setConfirmedNoToken(false);
notificationStore.success('Token generated and inserted into the command below.', 4000);
} catch (err) {
console.error('Failed to generate host agent token', err);
setGenerateError('Failed to generate host agent token. Confirm you are signed in as an administrator.');
notificationStore.error('Failed to generate API token', 6000);
notificationStore.error('Failed to generate host agent token. Confirm you are signed in as an administrator.', 6000);
} finally {
setIsGeneratingToken(false);
}
};
@ -303,152 +240,67 @@ export const HostAgents: Component<HostAgentsProps> = (props) => {
};
const resolvedToken = () => {
if (!requiresToken()) {
return 'disabled';
if (requiresToken()) {
return currentToken() || TOKEN_PLACEHOLDER;
}
return apiToken() || TOKEN_PLACEHOLDER;
return currentToken() || 'disabled';
};
return (
<div class="space-y-6">
<Card padding="lg" class="space-y-5">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">{installMeta().title}</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">{installMeta().description}</p>
</div>
<button
type="button"
onClick={() => setShowInstructions(!showInstructions())}
class="px-4 py-2 text-sm font-medium text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/30 rounded-lg hover:bg-blue-100 dark:hover:bg-blue-900/50 transition-colors"
>
{showInstructions() ? 'Hide' : 'Show'} instructions
</button>
<div class="space-y-1">
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Add a host agent</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Run this command on your host to start monitoring.</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{installMeta().description}</p>
</div>
<Show when={showInstructions()}>
<div class="space-y-5">
<Show when={variant === 'all'}>
<div class="space-y-4">
<div class="space-y-1">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">Step 1 · Choose the operating system</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
Pick the platform you are onboarding. The install commands adapt automatically.
</p>
</div>
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<For each={hostPlatformOptions}>
{(option) => {
const isActive = () => selectedPlatform() === option.id;
const Icon = option.icon;
return (
<button
type="button"
class={`flex flex-col items-start gap-3 rounded-lg border transition-colors p-4 text-left shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-gray-900 ${
isActive()
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 hover:border-blue-300 dark:hover:border-blue-500'
}`}
onClick={() => {
setSelectedPlatform(option.id);
setGenerateError(null);
setLatestRecord(null);
setApiToken(null);
setStepTwoComplete(false);
}}
>
<div class="flex items-center gap-3">
<div
class={`rounded-md p-2 ${
isActive()
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-200'
}`}
>
{typeof Icon === 'function' ? (
<Icon size={20} stroke-width={2} />
) : (
Icon
)}
</div>
<div class="flex-1">
<p class="font-semibold text-gray-900 dark:text-gray-100">{option.label}</p>
</div>
</div>
<p class="text-xs text-gray-600 dark:text-gray-400">{option.description}</p>
</button>
);
}}
</For>
</div>
<div class="space-y-5">
<Show when={requiresToken()}>
<div class="space-y-3">
<div class="space-y-1">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">Generate API token</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
Create a fresh token scoped to <code>{HOST_AGENT_SCOPE}</code>.
</p>
</div>
</Show>
<Show when={requiresToken()}>
<div class="space-y-4">
<div class="space-y-1">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{tokenStepLabel()}</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
Generate a scoped token for this host. Tokens minted here grant the <code>{HOST_AGENT_SCOPE}</code> permission only.
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Need additional scopes? Visit <a href="/settings/security" class="text-blue-600 dark:text-blue-300 underline hover:no-underline font-medium">Security API tokens</a> to create a bespoke credential.
</p>
</div>
<Show when={generateError()}>
<div class="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-xs text-red-800 dark:border-red-800 dark:bg-red-900/30 dark:text-red-200">
{generateError()}
</div>
</Show>
<Show when={latestRecord()}>
<div class="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-4 py-2 text-xs text-blue-800 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-200">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
<span>
Token <strong>{latestRecord()?.name}</strong> created ({latestRecord()?.prefix}{latestRecord()?.suffix}). Copy the full value from the pop-up and store it securelythis is the only time it is shown.
</span>
</div>
</Show>
<div class="flex gap-2">
<input
type="text"
value={tokenName()}
onInput={(event) => setTokenName(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !isGeneratingToken()) {
handleGenerateToken();
}
}}
placeholder="Token name (optional)"
class="flex-1 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:focus:border-blue-400 dark:focus:ring-blue-900/60"
/>
<button
type="button"
onClick={openGenerateTokenModal}
onClick={handleGenerateToken}
disabled={isGeneratingToken()}
class="inline-flex items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{isGeneratingToken() ? 'Generating…' : 'Generate token'}
{isGeneratingToken() ? 'Generating…' : hasToken() ? 'Generate another' : 'Generate token'}
</button>
<Show when={apiToken()}>
<div class="flex flex-col sm:flex-row sm:items-center gap-2">
<div class={`flex-1 rounded-lg border px-4 py-2 text-xs ${
stepTwoComplete()
? 'border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-900/20 dark:text-green-200'
: 'border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-200'
}`}>
{stepTwoComplete()
? 'Token inserted. Proceed to the install commands below.'
: 'Stored token detected. Press confirm to insert it into each command.'}
</div>
<button
type="button"
onClick={acknowledgeTokenUse}
disabled={stepTwoComplete()}
class={`inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
stepTwoComplete()
? 'bg-green-600 text-white cursor-default'
: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400'
}`}
>
{stepTwoComplete() ? 'Token inserted' : 'Insert token into commands'}
</button>
</div>
</Show>
</div>
</Show>
<Show when={latestRecord()}>
<div class="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-4 py-2 text-xs text-blue-800 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-200">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
<span>
Token <strong>{latestRecord()?.name}</strong> created ({latestRecord()?.prefix}{latestRecord()?.suffix}). Commands below now include this credential.
</span>
</div>
</Show>
</div>
</Show>
<Show when={!requiresToken()}>
<div class="space-y-3">
@ -457,147 +309,84 @@ export const HostAgents: Component<HostAgentsProps> = (props) => {
</div>
<button
type="button"
onClick={acknowledgeTokenUse}
disabled={stepTwoComplete()}
onClick={acknowledgeNoToken}
disabled={confirmedNoToken()}
class={`inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-colors ${
stepTwoComplete()
confirmedNoToken()
? 'bg-green-600 text-white cursor-default'
: 'bg-gray-900 text-white hover:bg-black dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white'
}`}
>
{stepTwoComplete() ? 'No token confirmed' : 'Confirm without token'}
{confirmedNoToken() ? 'No token confirmed' : 'Confirm without token'}
</button>
</div>
</Show>
<Show when={commandsUnlocked()}>
<div class="space-y-3">
<div class="flex items-center justify-between">
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">{commandStepLabel()}</h4>
<button
type="button"
onClick={async () => {
const firstSnippet = installMeta().snippets[0];
if (!firstSnippet) return;
const command = firstSnippet.command.replace(TOKEN_PLACEHOLDER, resolvedToken());
const success = await copyToClipboard(command);
if (typeof window !== 'undefined' && window.showToast) {
window.showToast(success ? 'success' : 'error', success ? 'Copied!' : 'Failed to copy');
}
}}
class="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors bg-blue-600 text-white hover:bg-blue-700"
>
Copy first command
</button>
</div>
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Install command</h4>
<div class="space-y-3">
<For each={installMeta().snippets}>
{(snippet) => (
<div class="space-y-2">
<div class="flex items-center justify-between gap-3">
<h5 class="text-sm font-semibold text-gray-700 dark:text-gray-200">{snippet.label}</h5>
<CopyButton
text={snippet.command.replace(
TOKEN_PLACEHOLDER,
resolvedToken(),
)}
>
Copy command
</CopyButton>
{(snippet) => {
const copyCommand = () =>
snippet.command.replace(
TOKEN_PLACEHOLDER,
resolvedToken(),
);
return (
<div class="space-y-2">
<div class="flex items-center justify-between gap-3">
<h5 class="text-sm font-semibold text-gray-700 dark:text-gray-200">{snippet.label}</h5>
<button
type="button"
onClick={async () => {
const success = await copyToClipboard(copyCommand());
if (typeof window !== 'undefined' && window.showToast) {
window.showToast(success ? 'success' : 'error', success ? 'Copied!' : 'Failed to copy');
}
}}
class="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors bg-blue-600 text-white hover:bg-blue-700"
>
Copy command
</button>
</div>
<pre class="overflow-x-auto rounded-md bg-gray-900/90 p-3 text-xs text-gray-100">
<code>{copyCommand()}</code>
</pre>
<Show when={snippet.note}>
<p class="text-xs text-gray-500 dark:text-gray-400">{snippet.note}</p>
</Show>
</div>
<pre class="overflow-x-auto rounded-md bg-gray-900/90 p-3 text-xs text-gray-100">
<code>
{snippet.command.replace(
TOKEN_PLACEHOLDER,
resolvedToken(),
)}
</code>
</pre>
<Show when={snippet.note}>
<p class="text-xs text-gray-500 dark:text-gray-400">{snippet.note}</p>
</Show>
</div>
)}
);
}}
</For>
</div>
</div>
</Show>
<Show when={requiresToken() && (!apiToken() || !stepTwoComplete())}>
<Show when={requiresToken() && !hasToken()}>
<p class="text-xs text-gray-500 dark:text-gray-400">
Generate a new token or confirm the stored one to unlock the install commands.
Generate a new token to unlock the install commands.
</p>
</Show>
<Show when={!requiresToken() && !stepTwoComplete()}>
<Show when={!requiresToken() && !confirmedNoToken() && !hasToken()}>
<p class="text-xs text-gray-500 dark:text-gray-400">Confirm the no-token setup to continue.</p>
</Show>
</div>
</Show>
</Card>
<Show when={showGenerateTokenModal()}>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-gray-800">
<div class="space-y-2">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Generate a new host agent token</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Pulse will create a scoped token for this host and automatically insert it into the install commands. You can manage or revoke tokens anytime from Security settings.
</p>
</div>
<div class="mt-4 space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300" for="host-agent-new-token-name">
Token name
</label>
<input
id="host-agent-new-token-name"
type="text"
value={newTokenName()}
onInput={(event) => setNewTokenName(event.currentTarget.value)}
class="w-full rounded border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:focus:border-blue-400 dark:focus:ring-blue-900/60"
placeholder="Host agent token"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">
Friendly names make it easier to audit tokens later (e.g. <code class="font-mono text-xs">host-lab-01</code>).
</p>
</div>
<div class="mt-6 flex justify-end gap-3">
<button
type="button"
onClick={() => {
setShowGenerateTokenModal(false);
setNewTokenName('');
setGenerateError(null);
}}
class="rounded-lg px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
type="button"
onClick={handleCreateToken}
disabled={isGeneratingToken()}
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-400"
>
{isGeneratingToken() ? 'Generating…' : 'Generate token'}
</button>
</div>
</div>
</div>
</Show>
</Card>
<Card padding="lg" class="space-y-5">
<div class="flex items-center justify-between">
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Reporting hosts</h3>
<span class="text-sm text-gray-500 dark:text-gray-400">{hosts().length} connected</span>
<span class="text-sm text-gray-500 dark:text-gray-400">{allHosts().length} connected</span>
</div>
<Show
when={hosts().length > 0}
when={allHosts().length > 0}
fallback={
<p class="text-sm text-gray-600 dark:text-gray-400">
{variant === 'windows'
? 'No Windows hosts have reported yet. Run the PowerShell installation script above as Administrator to deploy the agent.'
: 'No host agents are reporting yet. Deploy the binary using the commands above to see hosts listed here.'}
No host agents are reporting yet. Deploy the agent using the commands above to see hosts listed here.
</p>
}
>
@ -615,7 +404,7 @@ export const HostAgents: Component<HostAgentsProps> = (props) => {
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-800">
<For each={hosts()}>
<For each={allHosts()}>
{(host) => {
const [isDeleting, setIsDeleting] = createSignal(false);

View file

@ -0,0 +1,77 @@
import type { Component } from 'solid-js';
import { For, type JSX } from 'solid-js';
import SquareTerminal from 'lucide-solid/icons/square-terminal';
type HostsSection = 'linux' | 'macos' | 'windows';
interface HostsSectionNavProps {
current: HostsSection;
onSelect: (section: HostsSection) => void;
class?: string;
}
const AppleIcon = () => (
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M14.94 5.19A4.38 4.38 0 0 0 16 2a4.44 4.44 0 0 0-3 1.52 4.17 4.17 0 0 0-1 3.09 3.69 3.69 0 0 0 2.94-1.42zm2.52 7.44a4.51 4.51 0 0 1 2.16-3.81 4.66 4.66 0 0 0-3.66-2c-1.56-.16-3 .91-3.83.91s-2-.89-3.3-.87A4.92 4.92 0 0 0 4.69 9.39C2.93 12.45 4.24 17 6 19.47c.8 1.21 1.8 2.58 3.12 2.53s1.75-.82 3.28-.82 2 .82 3.3.79 2.22-1.24 3.06-2.45a11 11 0 0 0 1.38-2.85 4.41 4.41 0 0 1-2.68-4.08z" />
</svg>
);
const WindowsIcon = () => (
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M0 3.449L9.75 2.1v9.451H0m10.949-9.602L24 0v11.4H10.949M0 12.6h9.75v9.451L0 20.699M10.949 12.6H24V24l-12.9-1.801" />
</svg>
);
const allSections: Array<{
id: HostsSection;
label: string;
icon: Component<{ size?: number; 'stroke-width'?: number }>;
}> = [
{
id: 'linux',
label: 'Linux',
icon: SquareTerminal,
},
{
id: 'macos',
label: 'macOS',
icon: AppleIcon,
},
{
id: 'windows',
label: 'Windows',
icon: WindowsIcon,
},
];
export const HostsSectionNav: Component<HostsSectionNavProps> = (props) => {
const baseClasses =
'inline-flex items-center gap-2 px-2 sm:px-3 py-1 text-xs sm:text-sm font-medium border-b-2 border-transparent text-gray-600 dark:text-gray-400 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-gray-900';
return (
<div class={`flex flex-wrap items-center gap-3 sm:gap-4 ${props.class ?? ''}`} aria-label="Host platform sections">
<For each={allSections}>
{(section) => {
const isActive = () => section.id === props.current;
const classes = () => isActive()
? `${baseClasses} text-blue-600 dark:text-blue-300 border-blue-500 dark:border-blue-400`
: `${baseClasses} hover:text-blue-500 dark:hover:text-blue-300 hover:border-blue-300/70 dark:hover:border-blue-500/50`;
const Icon = section.icon;
return (
<button
type="button"
class={classes()}
onClick={() => props.onSelect(section.id)}
aria-pressed={isActive()}
>
<Icon size={16} stroke-width={2} />
<span>{section.label}</span>
</button>
);
}}
</For>
</div>
);
};

View file

@ -14,6 +14,7 @@ import { QuickSecuritySetup } from './QuickSecuritySetup';
import { SecurityPostureSummary } from './SecurityPostureSummary';
import { PveNodesTable, PbsNodesTable, PmgNodesTable } from './ConfiguredNodeTables';
import { SettingsSectionNav } from './SettingsSectionNav';
import { HostsSectionNav } from './HostsSectionNav';
import { SettingsAPI } from '@/api/settings';
import { NodesAPI } from '@/api/nodes';
import { UpdatesAPI } from '@/api/updates';
@ -395,6 +396,7 @@ const Settings: Component<SettingsProps> = (props) => {
const activeTab = () => currentTab();
const [selectedAgent, setSelectedAgent] = createSignal<AgentKey>('pve');
const [selectedHostPlatform, setSelectedHostPlatform] = createSignal<'linux' | 'macos' | 'windows'>('linux');
const agentPaths: Record<AgentKey, string> = {
pve: '/settings/pve',
@ -417,6 +419,10 @@ const Settings: Component<SettingsProps> = (props) => {
}
};
const handleSelectHostPlatform = (platform: 'linux' | 'macos' | 'windows') => {
setSelectedHostPlatform(platform);
};
const setActiveTab = (tab: SettingsTab) => {
if (tab === 'proxmox' && selectedAgent() === 'podman') {
setSelectedAgent('pve');
@ -2936,7 +2942,12 @@ const Settings: Component<SettingsProps> = (props) => {
{/* Servers Platform Tab */}
<Show when={activeTab() === 'hosts'}>
<HostAgents variant="all" />
<HostsSectionNav
current={selectedHostPlatform()}
onSelect={handleSelectHostPlatform}
class="mb-6"
/>
<HostAgents variant={selectedHostPlatform()} />
</Show>
{/* Podman Tab */}

View file

@ -4,13 +4,47 @@ import type { JSX } from 'solid-js';
interface CopyButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
text: string;
children: JSX.Element;
onCopied?: () => void;
}
export function CopyButton(props: CopyButtonProps) {
const [local, others] = splitProps(props, ['text', 'children', 'class', 'onClick']);
const [local, others] = splitProps(props, ['text', 'children', 'class', 'onClick', 'onCopied']);
const [copied, setCopied] = createSignal(false);
let resetTimeout: number | undefined;
const copyText = async (text: string): Promise<boolean> => {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (error) {
console.warn('Clipboard API copy failed, attempting fallback copy.', error);
}
}
if (typeof document === 'undefined') {
return false;
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.left = '-999999px';
textarea.style.top = '-999999px';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
try {
return document.execCommand('copy');
} catch (error) {
console.error('Fallback copy failed', error);
return false;
} finally {
document.body.removeChild(textarea);
}
};
const handleClick = async (event: MouseEvent) => {
const handler = local.onClick as
| ((event: MouseEvent) => void)
@ -26,13 +60,18 @@ export function CopyButton(props: CopyButtonProps) {
return;
}
try {
await navigator.clipboard.writeText(local.text);
const success = await copyText(local.text);
if (success) {
setCopied(true);
window.clearTimeout(resetTimeout);
resetTimeout = window.setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error('Failed to copy to clipboard', error);
if (typeof local.onCopied === 'function') {
try {
local.onCopied();
} catch (error) {
console.error('onCopied handler failed', error);
}
}
}
};

View file

@ -404,9 +404,33 @@ elif [[ "$PLATFORM" == "darwin" ]] && command -v launchctl &> /dev/null; then
security delete-generic-password -s "pulse-host-agent" -a "$USER" 2>/dev/null || true
# Add token to Keychain
if security add-generic-password -s "pulse-host-agent" -a "$USER" -w "$PULSE_TOKEN" -U 2>/dev/null; then
log_success "Token stored securely in macOS Keychain"
USE_KEYCHAIN=true
KEYCHAIN_SERVICE="pulse-host-agent"
KEYCHAIN_ACCOUNT="$USER"
KEYCHAIN_APPS=(
"/usr/local/bin/pulse-host-agent"
"/usr/local/bin/pulse-host-agent-wrapper.sh"
"/usr/bin/security"
)
KEYCHAIN_ARGS=()
for app in "${KEYCHAIN_APPS[@]}"; do
KEYCHAIN_ARGS+=(-T "$app")
done
if security add-generic-password \
-s "$KEYCHAIN_SERVICE" \
-a "$KEYCHAIN_ACCOUNT" \
-w "$PULSE_TOKEN" \
-U \
"${KEYCHAIN_ARGS[@]}" 2>/dev/null; then
if security find-generic-password -s "$KEYCHAIN_SERVICE" -a "$KEYCHAIN_ACCOUNT" -w >/dev/null 2>&1; then
log_success "Token stored securely in macOS Keychain"
USE_KEYCHAIN=true
else
log_warn "Token saved but Keychain denied non-interactive read access"
log_info "Will fall back to embedding token in the launchd plist"
USE_KEYCHAIN=false
fi
else
log_warn "Failed to store token in Keychain, will use plist instead"
log_info "You may need to grant Keychain access permissions"
@ -419,14 +443,21 @@ elif [[ "$PLATFORM" == "darwin" ]] && command -v launchctl &> /dev/null; then
# Create wrapper script if using Keychain
if [[ "$USE_KEYCHAIN" == true ]]; then
WRAPPER_SCRIPT="/usr/local/bin/pulse-host-agent-wrapper.sh"
TMP_WRAPPER=$(mktemp)
cat > "$WRAPPER_SCRIPT" <<'WRAPPER_EOF'
cat > "$TMP_WRAPPER" <<'WRAPPER_EOF'
#!/bin/bash
# Pulse Host Agent Wrapper - Reads token from Keychain
set -e
set -u
LOG_FILE="$HOME/Library/Logs/Pulse/host-agent-wrapper.log"
mkdir -p "$(dirname "$LOG_FILE")"
# Read token from Keychain
PULSE_TOKEN=$(security find-generic-password -s "pulse-host-agent" -w 2>/dev/null || echo "")
if ! PULSE_TOKEN=$(security find-generic-password -s "pulse-host-agent" -a "$USER" -w 2>/dev/null); then
echo "$(date -Is) pulse-host-agent-wrapper: failed to read token from Keychain" >>"$LOG_FILE"
PULSE_TOKEN=""
fi
# Export for agent to use
export PULSE_TOKEN
@ -435,7 +466,22 @@ export PULSE_TOKEN
exec /usr/local/bin/pulse-host-agent "$@"
WRAPPER_EOF
chmod +x "$WRAPPER_SCRIPT"
if ! sudo mv "$TMP_WRAPPER" "$WRAPPER_SCRIPT"; then
if ! mv "$TMP_WRAPPER" "$WRAPPER_SCRIPT" 2>/dev/null; then
rm -f "$TMP_WRAPPER"
log_error "Failed to write Keychain wrapper to $WRAPPER_SCRIPT. Try re-running with sudo."
exit 1
fi
fi
if ! sudo chmod 755 "$WRAPPER_SCRIPT" 2>/dev/null && ! chmod 755 "$WRAPPER_SCRIPT" 2>/dev/null; then
log_error "Failed to set execute permissions on $WRAPPER_SCRIPT."
exit 1
fi
if command -v chown &>/dev/null; then
sudo chown root:wheel "$WRAPPER_SCRIPT" 2>/dev/null || sudo chown root:root "$WRAPPER_SCRIPT" 2>/dev/null || true
fi
log_success "Created Keychain wrapper script"
# Create plist using wrapper (token not in plist!)
@ -502,8 +548,23 @@ EOF
# Set restrictive permissions on plist
chmod 600 "$LAUNCHD_PLIST"
launchctl load "$LAUNCHD_PLIST"
log_success "Launchd service enabled and started"
LAUNCH_TARGET="gui/$(id -u)"
# Attempt to unload any existing service instance
if launchctl bootout "$LAUNCH_TARGET" "$LAUNCHD_PLIST" 2>/dev/null; then
log_info "Replaced existing launchd service definition"
fi
if launchctl bootstrap "$LAUNCH_TARGET" "$LAUNCHD_PLIST"; then
launchctl enable "$LAUNCH_TARGET/com.pulse.host-agent" 2>/dev/null || true
launchctl kickstart -k "$LAUNCH_TARGET/com.pulse.host-agent" 2>/dev/null || true
log_success "Launchd service enabled and started"
else
log_error "Failed to load launchd service. Try running:"
echo " launchctl bootstrap $LAUNCH_TARGET $LAUNCHD_PLIST"
echo " launchctl kickstart -k $LAUNCH_TARGET/com.pulse.host-agent"
exit 1
fi
else
log_warn "Automatic service setup not available for this platform"
log_info "To run the agent manually:"