sudo commands through ephemeral terminal

This commit is contained in:
2026-02-19 21:43:37 +00:00
parent 04c79a09f0
commit 7c0b11c5a5
6 changed files with 240 additions and 46 deletions
@@ -1,10 +1,22 @@
import { useState } from 'react';
import { Copy, Check } from 'lucide-react';
import { Copy, Check, Play } from 'lucide-react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { useClient } from 'hooks/useClient';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { useServerSettings } from '@/state/useServerSettings';
import { RUN_COMMAND_CHANNEL, type RunCommandState } from './run-command-channel';
type VersionInfo = { version: string | null; path: string | null; globalPath: string | null };
type ClaudeAuthInfo = { authenticated: boolean; loggedIn?: boolean; subscriptionType?: string };
@@ -106,15 +118,28 @@ export const AIHarnessesSection = () => {
setTimeout(() => setCopied(null), 1500);
};
const CopyCommand = ({ command }: { command: string }) => (
const [, setRunCommand] = usePanelChannel<RunCommandState>(RUN_COMMAND_CHANNEL, null);
const [confirmCommand, setConfirmCommand] = useState<{ command: string; refetchKeys: string[] } | null>(null);
const CopyCommand = ({ command, refetchKeys }: { command: string; refetchKeys: string[] }) => (
<div className="mt-2 text-xs text-amber-600">
Not globally accessible. Run:
<div className="flex items-center gap-1 mt-1">
<code className="flex-1 bg-duck-dark/5 rounded px-2 py-1 text-duck-dark/70">{command}</code>
<button
type="button"
onClick={() => setConfirmCommand({ command, refetchKeys })}
className="shrink-0 p-1 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors"
title="Run in terminal"
>
<Play className="h-3.5 w-3.5 text-duck-teal" />
</button>
<button
type="button"
onClick={() => copyToClipboard(command)}
className="shrink-0 p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
title="Copy command"
>
{copied === command ? (
<Check className="h-3.5 w-3.5 text-green-600" />
@@ -127,6 +152,7 @@ export const AIHarnessesSection = () => {
);
return (
<>
<div className="flex flex-col gap-4">
<div>
<label className="flex items-center gap-3 cursor-pointer">
@@ -163,7 +189,7 @@ export const AIHarnessesSection = () => {
</div>
)}
{!opencodeVersion.globalPath && opencodeVersion.path && (
<CopyCommand command={`sudo ln -s ${opencodeVersion.path} /usr/local/bin/opencode`} />
<CopyCommand command={`sudo ln -s ${opencodeVersion.path} /usr/local/bin/opencode`} refetchKeys={['OPENCODE_VERSION']} />
)}
</>
) : (
@@ -215,7 +241,7 @@ export const AIHarnessesSection = () => {
</div>
)}
{!claudeVersion.globalPath && claudeVersion.path && (
<CopyCommand command={`sudo ln -s ${claudeVersion.path} /usr/local/bin/claude`} />
<CopyCommand command={`sudo ln -s ${claudeVersion.path} /usr/local/bin/claude`} refetchKeys={['CLAUDE_CODE_VERSION']} />
)}
</>
) : (
@@ -249,7 +275,7 @@ export const AIHarnessesSection = () => {
<div>{piMonoVersion.version}</div>
<div>{piMonoVersion.path}</div>
{!piMonoVersion.globalPath && piMonoVersion.path && (
<CopyCommand command={`sudo ln -s ${piMonoVersion.path} /usr/local/bin/pi`} />
<CopyCommand command={`sudo ln -s ${piMonoVersion.path} /usr/local/bin/pi`} refetchKeys={['PI_MONO_VERSION']} />
)}
</>
) : (
@@ -266,5 +292,30 @@ export const AIHarnessesSection = () => {
)}
</div>
</div>
<AlertDialog open={!!confirmCommand} onOpenChange={(open) => !open && setConfirmCommand(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Run with elevated privileges</AlertDialogTitle>
<AlertDialogDescription>
You are about to run a command with elevated privileges (sudo). Are you sure?
</AlertDialogDescription>
</AlertDialogHeader>
<code className="text-xs bg-duck-dark/5 rounded px-3 py-2 text-duck-dark/70 break-all">{confirmCommand?.command}</code>
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer">Cancel</AlertDialogCancel>
<AlertDialogAction
className="cursor-pointer bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold"
onClick={() => {
if (confirmCommand) setRunCommand(confirmCommand);
setConfirmCommand(null);
}}
>
Run
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};
@@ -0,0 +1,6 @@
export type RunCommandState = {
command: string;
refetchKeys: string[];
} | null;
export const RUN_COMMAND_CHANNEL = 'system-settings:run-command';
@@ -1,6 +1,6 @@
import { useState, useEffect, useMemo } from 'react';
import { toast } from 'sonner';
import { Terminal, Eye, Trash2, Bot, Server, Puzzle, Settings } from 'lucide-react';
import { Terminal, Eye, Trash2, Bot, Server, Puzzle, Settings, X } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
@@ -10,8 +10,11 @@ import { Switch } from '@/components/ui/switch';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useQueryClient } from '@tanstack/react-query';
import type { LayoutNode, PanelComponents } from '@/components/Workspace';
import { WorkspaceLayout } from '@/components/Workspace';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { TerminalView } from 'apps/Terminal';
import { appRegistry } from '../Workspaces/app-registry';
import { createSettingsPanelComponents, type SettingsSectionGroup } from './SettingsPanel';
import { useSettings } from '@/state/useSettings';
@@ -27,6 +30,7 @@ import {
import type { UserSettings } from '@/state/types/user-settings';
import { AIHarnessesSection } from './ServerSettings/AIHarnessesSection';
import { PluginsSection } from './ServerSettings/PluginsSection';
import { RUN_COMMAND_CHANNEL, type RunCommandState } from './ServerSettings/run-command-channel';
const groups: SettingsSectionGroup[] = [
{
@@ -54,7 +58,7 @@ const { Sidebar, Content } = createSettingsPanelComponents({
groups,
});
const layout: LayoutNode = {
const baseLayout: LayoutNode = {
type: 'group',
id: 'system-root',
direction: 'horizontal',
@@ -64,11 +68,87 @@ const layout: LayoutNode = {
],
};
const splitLayout: LayoutNode = {
type: 'group',
id: 'system-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'system-left', appType: null }, size: 20 },
{
node: {
type: 'group',
id: 'system-right-group',
direction: 'vertical',
children: [
{ node: { type: 'panel', id: 'system-right', appType: null }, size: 50 },
{ node: { type: 'panel', id: 'system-terminal', appType: null }, size: 50 },
],
},
size: 80,
},
],
};
const SystemTerminalPanel = () => {
const queryClient = useQueryClient();
const [state, setState] = usePanelChannel<RunCommandState>(RUN_COMMAND_CHANNEL, null);
const [session, setSession] = useState<{ id: string; command: string } | null>(null);
useEffect(() => {
if (state && (!session || session.command !== state.command)) {
setSession({ id: `run-cmd-${Date.now()}`, command: state.command });
} else if (!state) {
setSession(null);
}
}, [state]);
const close = () => setState(null);
const onCommandDone = (exitCode: number, output: string) => {
if (state) {
for (const key of state.refetchKeys) {
queryClient.invalidateQueries({ queryKey: [key] });
}
}
if (exitCode === 0) {
toast.success('Command completed successfully');
} else {
toast.error(output || `Command failed with exit code ${exitCode}`, { duration: 8000 });
}
setState(null);
};
if (!state || !session) return null;
return (
<div className="h-full flex flex-col">
<div className="shrink-0 px-4 py-1.5 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
<span className="text-xs font-medium text-duck-dark/50 dark:text-foreground/50 flex-1">Run Command</span>
<button onClick={close} className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors">
<X className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
</button>
</div>
<TerminalView
className="flex-1"
sandboxed={false}
command={session.command}
sessionId={session.id}
onCommandDone={onCommandDone}
/>
</div>
);
};
export const SystemSettings = () => {
const [runCommand] = usePanelChannel<RunCommandState>(RUN_COMMAND_CHANNEL, null);
const layout = useMemo(() => (runCommand ? splitLayout : baseLayout), [runCommand]);
const panelComponents: PanelComponents = useMemo(
() => ({
'system-left': Sidebar,
'system-right': Content,
'system-terminal': SystemTerminalPanel,
}),
[],
);