feat(KB-643): Add project scripts management UI to dashboard

- Add scripts field to ProjectSettings type for storing project-specific commands
- Create ScriptsModal component with CRUD operations and execution support
- Add API endpoints and client functions for script management
- Integrate scripts modal into dashboard header with button trigger
- Add comprehensive tests for ScriptsModal component
This commit is contained in:
gsxdsm
2026-04-01 01:48:19 -07:00
parent d2354560a0
commit e8f292e350
8 changed files with 1114 additions and 4 deletions

View File

@@ -663,6 +663,10 @@ export interface ProjectSettings {
* Must be set together with `titleSummarizerProvider`. Falls back to planningModelId,
* then defaultModelId if not specified. */
titleSummarizerModelId?: string;
/** Project-defined shell scripts for quick command execution.
* Key is the script name, value is the shell command to execute.
* Script names must be alphanumeric with hyphens and underscores only. */
scripts?: Record<string, string>;
}
/**
@@ -727,6 +731,7 @@ export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = {
autoSummarizeTitles: false,
titleSummarizerProvider: undefined,
titleSummarizerModelId: undefined,
scripts: {},
};
/**
@@ -788,6 +793,7 @@ export const PROJECT_SETTINGS_KEYS: ReadonlyArray<keyof ProjectSettings> = [
"autoSummarizeTitles",
"titleSummarizerProvider",
"titleSummarizerModelId",
"scripts",
] as const;
export interface BoardConfig {

View File

@@ -24,6 +24,7 @@ import { ActivityLogModal } from "./components/ActivityLogModal";
import { WorkflowStepManager } from "./components/WorkflowStepManager";
import { AgentListModal } from "./components/AgentListModal";
import { AgentsView } from "./components/AgentsView";
import { ScriptsModal } from "./components/ScriptsModal";
import { useTasks } from "./hooks/useTasks";
import { useProjects } from "./hooks/useProjects";
import { useCurrentProject } from "./hooks/useCurrentProject";
@@ -48,6 +49,8 @@ function AppInner() {
const [gitManagerOpen, setGitManagerOpen] = useState(false);
const [workflowStepsOpen, setWorkflowStepsOpen] = useState(false);
const [agentsOpen, setAgentsOpen] = useState(false);
const [scriptsOpen, setScriptsOpen] = useState(false);
const [terminalInitialCommand, setTerminalInitialCommand] = useState<string | undefined>(undefined);
const [settingsInitialSection, setSettingsInitialSection] = useState<SectionId | undefined>(undefined);
const [maxConcurrent, setMaxConcurrent] = useState(2);
const [rootDir, setRootDir] = useState<string>(".");
@@ -332,10 +335,6 @@ function AppInner() {
setTerminalOpen((prev) => !prev);
}, []);
const handleTerminalClose = useCallback(() => {
setTerminalOpen(false);
}, []);
const handleOpenFiles = useCallback(() => {
setFilesOpen(true);
}, []);
@@ -361,6 +360,22 @@ function AppInner() {
const handleOpenAgents = useCallback(() => setAgentsOpen(true), []);
const handleCloseAgents = useCallback(() => setAgentsOpen(false), []);
// Scripts handlers
const handleOpenScripts = useCallback(() => setScriptsOpen(true), []);
const handleCloseScripts = useCallback(() => setScriptsOpen(false), []);
const handleRunScript = useCallback((name: string, command: string) => {
setTerminalInitialCommand(command);
setScriptsOpen(false);
setTerminalOpen(true);
addToast(`Running script: ${name}`, "success");
}, [addToast]);
const handleTerminalClose = useCallback(() => {
setTerminalOpen(false);
setTerminalInitialCommand(undefined);
}, []);
// Setup wizard complete handler
const handleSetupComplete = useCallback((project: ProjectInfo) => {
setSetupWizardOpen(false);
@@ -450,6 +465,7 @@ function AppInner() {
onOpenGitManager={handleOpenGitManager}
onOpenWorkflowSteps={() => setWorkflowStepsOpen(true)}
onOpenAgents={handleOpenAgents}
onOpenScripts={handleOpenScripts}
onToggleTerminal={handleToggleTerminal}
onOpenFiles={handleOpenFiles}
filesOpen={filesOpen}
@@ -518,6 +534,13 @@ function AppInner() {
<TerminalModal
isOpen={terminalOpen}
onClose={handleTerminalClose}
initialCommand={terminalInitialCommand}
/>
<ScriptsModal
isOpen={scriptsOpen}
onClose={handleCloseScripts}
addToast={addToast}
onRunScript={handleRunScript}
/>
{filesOpen && (
<FileBrowserModal

View File

@@ -1910,3 +1910,39 @@ export interface TaskDiff {
export function fetchTaskDiff(taskId: string): Promise<TaskDiff> {
return api<TaskDiff>(`/tasks/${encodeURIComponent(taskId)}/diff`);
}
// ── Scripts API ───────────────────────────────────────────────────────────
/** Script execution result */
export interface ScriptRunResult {
output: string;
exitCode: number;
}
/** Fetch all project-defined scripts */
export function fetchScripts(): Promise<Record<string, string>> {
return api<Record<string, string>>("/scripts");
}
/** Add or update a script */
export function addScript(name: string, command: string): Promise<Record<string, string>> {
return api<Record<string, string>>("/scripts", {
method: "POST",
body: JSON.stringify({ name, command }),
});
}
/** Remove a script by name */
export function removeScript(name: string): Promise<Record<string, string>> {
return api<Record<string, string>>(`/scripts/${encodeURIComponent(name)}`, {
method: "DELETE",
});
}
/** Execute a script with optional arguments */
export function runScript(name: string, args?: string[]): Promise<ScriptRunResult> {
return api<ScriptRunResult>(`/scripts/${encodeURIComponent(name)}/run`, {
method: "POST",
body: JSON.stringify({ args }),
});
}

View File

@@ -28,6 +28,7 @@ export interface HeaderProps {
onOpenGitManager?: () => void;
onOpenWorkflowSteps?: () => void;
onOpenAgents?: () => void;
onOpenScripts?: () => void;
onToggleTerminal?: () => void;
/** Opens the top-level workspace-aware file browser modal. */
onOpenFiles?: () => void;
@@ -74,6 +75,7 @@ export function Header({
onOpenGitManager,
onOpenWorkflowSteps,
onOpenAgents,
onOpenScripts,
onToggleTerminal,
onOpenFiles,
filesOpen,
@@ -393,6 +395,18 @@ export function Header({
</button>
)}
{/* Scripts - desktop only */}
{!isMobile && onOpenScripts && (
<button
className="btn-icon"
onClick={onOpenScripts}
title="Scripts"
data-testid="scripts-btn"
>
<Terminal size={16} />
</button>
)}
{/* Settings - always inline on desktop */}
{!isMobile && (
<button className="btn-icon" onClick={onOpenSettings} title="Settings">
@@ -524,6 +538,17 @@ export function Header({
<span>Manage Agents</span>
</button>
)}
{onOpenScripts && (
<button
className="mobile-overflow-item"
onClick={() => handleOverflowAction(onOpenScripts)}
role="menuitem"
data-testid="overflow-scripts-btn"
>
<Terminal size={16} />
<span>Scripts</span>
</button>
)}
<button
className="mobile-overflow-item"
onClick={() => handleOverflowAction(onOpenSettings)}

View File

@@ -0,0 +1,502 @@
import { useState, useEffect, useCallback } from "react";
import { X, Plus, Play, Trash2, Terminal, Check, AlertCircle } from "lucide-react";
import {
fetchScripts,
addScript,
removeScript,
type ScriptRunResult,
} from "../api";
import type { ToastType } from "../hooks/useToast";
interface ScriptsModalProps {
isOpen: boolean;
onClose: () => void;
addToast: (message: string, type?: ToastType) => void;
onRunScript?: (name: string, command: string) => void;
}
interface ScriptFormData {
name: string;
command: string;
}
const EMPTY_FORM: ScriptFormData = {
name: "",
command: "",
};
export function ScriptsModal({ isOpen, onClose, addToast, onRunScript }: ScriptsModalProps) {
const [scripts, setScripts] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [isCreating, setIsCreating] = useState(false);
const [editingName, setEditingName] = useState<string | null>(null);
const [form, setForm] = useState<ScriptFormData>(EMPTY_FORM);
const [saving, setSaving] = useState(false);
const [deleteConfirmName, setDeleteConfirmName] = useState<string | null>(null);
const [validationError, setValidationError] = useState<string | null>(null);
const loadScripts = useCallback(async () => {
if (!isOpen) return;
try {
setLoading(true);
const data = await fetchScripts();
setScripts(data);
} catch (err: any) {
addToast(err.message || "Failed to load scripts", "error");
} finally {
setLoading(false);
}
}, [isOpen, addToast]);
useEffect(() => {
if (isOpen) {
loadScripts();
}
}, [isOpen, loadScripts]);
const validateScriptName = (name: string): string | null => {
if (!name.trim()) {
return "Script name is required";
}
if (!/^[a-zA-Z0-9_-]+$/.test(name.trim())) {
return "Name must be alphanumeric with hyphens and underscores only (no spaces)";
}
// Check for reserved names
const reservedNames = ["run", "list", "add", "remove", "delete", "help"];
if (reservedNames.includes(name.trim().toLowerCase())) {
return `Script name '${name.trim()}' is reserved`;
}
return null;
};
const handleCreate = useCallback(() => {
setIsCreating(true);
setEditingName(null);
setForm(EMPTY_FORM);
setValidationError(null);
}, []);
const handleEdit = useCallback((name: string, command: string) => {
setIsCreating(false);
setEditingName(name);
setForm({ name, command });
setValidationError(null);
}, []);
const handleCancel = useCallback(() => {
setIsCreating(false);
setEditingName(null);
setForm(EMPTY_FORM);
setValidationError(null);
}, []);
const handleSave = useCallback(async () => {
const trimmedName = form.name.trim();
const trimmedCommand = form.command.trim();
// Validate name
const nameError = validateScriptName(trimmedName);
if (nameError) {
setValidationError(nameError);
return;
}
if (!trimmedCommand) {
setValidationError("Command is required");
return;
}
setSaving(true);
setValidationError(null);
try {
await addScript(trimmedName, trimmedCommand);
addToast(
isCreating ? `Script '${trimmedName}' created` : `Script '${trimmedName}' updated`,
"success"
);
setIsCreating(false);
setEditingName(null);
setForm(EMPTY_FORM);
await loadScripts();
} catch (err: any) {
const message = err.message || "Failed to save script";
setValidationError(message);
addToast(message, "error");
} finally {
setSaving(false);
}
}, [form, isCreating, addToast, loadScripts]);
const handleDelete = useCallback(
async (name: string) => {
try {
await removeScript(name);
addToast(`Script '${name}' deleted`, "success");
setDeleteConfirmName(null);
if (editingName === name) {
setEditingName(null);
setForm(EMPTY_FORM);
}
await loadScripts();
} catch (err: any) {
addToast(err.message || "Failed to delete script", "error");
}
},
[editingName, addToast, loadScripts]
);
const handleRunScript = useCallback(
(name: string, command: string) => {
if (onRunScript) {
onRunScript(name, command);
} else {
addToast("Terminal not available", "error");
}
},
[onRunScript, addToast]
);
if (!isOpen) return null;
const isEditing = isCreating || editingName !== null;
const scriptEntries = Object.entries(scripts).sort(([a], [b]) => a.localeCompare(b));
return (
<div className="modal-overlay" onClick={onClose} data-testid="scripts-modal">
<div
className="modal scripts-modal"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-label="Scripts Manager"
>
{/* Header */}
<div className="modal-header">
<h2 style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<Terminal size={18} />
Scripts
</h2>
<button className="btn-icon" onClick={onClose} aria-label="Close">
<X size={16} />
</button>
</div>
<div className="modal-body" style={{ padding: "16px", maxHeight: "70vh", overflowY: "auto" }}>
{loading ? (
<div
style={{
textAlign: "center",
padding: "32px",
color: "var(--text-secondary)",
}}
data-testid="scripts-loading"
>
Loading...
</div>
) : (
<>
{/* Script List */}
{!isEditing && (
<>
{scriptEntries.length === 0 && (
<div
style={{
textAlign: "center",
padding: "32px",
color: "var(--text-secondary)",
fontSize: "14px",
}}
data-testid="scripts-empty-state"
>
No scripts defined yet. Add your first script to run quick commands.
</div>
)}
{scriptEntries.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
{scriptEntries.map(([name, command]) => (
<div
key={name}
className="script-card"
data-testid={`script-item-${name}`}
style={{
padding: "12px 16px",
border: "1px solid var(--border-primary)",
borderRadius: "8px",
background: "var(--bg-secondary)",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
marginBottom: "4px",
}}
>
<span style={{ fontWeight: 600, fontSize: "14px" }}>{name}</span>
</div>
<div
style={{
fontSize: "12px",
color: "var(--text-secondary)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontFamily: "monospace",
}}
title={command}
>
{command.length > 60 ? `${command.slice(0, 60)}...` : command}
</div>
</div>
<div
style={{
display: "flex",
gap: "4px",
marginLeft: "8px",
flexShrink: 0,
}}
>
<button
className="btn-icon"
onClick={() => handleRunScript(name, command)}
title={`Run ${name}`}
aria-label={`Run script ${name}`}
data-testid={`run-script-${name}`}
>
<Play size={14} />
</button>
<button
className="btn-icon"
onClick={() => handleEdit(name, command)}
title="Edit"
aria-label={`Edit script ${name}`}
data-testid={`edit-script-${name}`}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
</svg>
</button>
{deleteConfirmName === name ? (
<div
style={{ display: "flex", gap: "4px", alignItems: "center" }}
>
<button
className="btn-icon"
onClick={() => handleDelete(name)}
title="Confirm delete"
aria-label={`Confirm delete script ${name}`}
style={{ color: "var(--status-error, #ef4444)" }}
>
<Check size={14} />
</button>
<button
className="btn-icon"
onClick={() => setDeleteConfirmName(null)}
title="Cancel delete"
aria-label="Cancel delete"
>
<X size={14} />
</button>
</div>
) : (
<button
className="btn-icon"
onClick={() => setDeleteConfirmName(name)}
title="Delete"
aria-label={`Delete script ${name}`}
data-testid={`delete-script-${name}`}
>
<Trash2 size={14} />
</button>
)}
</div>
</div>
</div>
))}
</div>
)}
</>
)}
{/* Edit / Create form */}
{isEditing && (
<div
style={{
padding: "16px",
border: "1px solid var(--border-primary)",
borderRadius: "8px",
background: "var(--bg-secondary)",
}}
data-testid="script-form"
>
<h3 style={{ margin: "0 0 12px", fontSize: "14px", fontWeight: 600 }}>
{isCreating ? "New Script" : "Edit Script"}
</h3>
{/* Validation error */}
{validationError && (
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
padding: "8px 12px",
marginBottom: "12px",
background: "rgba(239, 68, 68, 0.1)",
border: "1px solid rgba(239, 68, 68, 0.3)",
borderRadius: "6px",
color: "var(--status-error, #ef4444)",
fontSize: "13px",
}}
data-testid="script-validation-error"
>
<AlertCircle size={14} />
{validationError}
</div>
)}
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
{/* Name */}
<div>
<label
style={{
display: "block",
fontSize: "12px",
color: "var(--text-secondary)",
marginBottom: "4px",
}}
>
Script Name
</label>
<input
type="text"
value={form.name}
onChange={(e) =>
setForm((prev) => ({ ...prev, name: e.target.value }))
}
placeholder="e.g. build, test, deploy"
disabled={!isCreating}
style={{
width: "100%",
padding: "8px 12px",
borderRadius: "6px",
border: "1px solid var(--border-primary)",
background: isCreating ? "var(--bg-primary)" : "var(--bg-tertiary)",
color: "var(--text-primary)",
fontSize: "13px",
}}
data-testid="script-name-input"
/>
<div
style={{
fontSize: "11px",
color: "var(--text-secondary)",
marginTop: "4px",
}}
>
Alphanumeric with hyphens and underscores only. No spaces.
</div>
</div>
{/* Command */}
<div>
<label
style={{
display: "block",
fontSize: "12px",
color: "var(--text-secondary)",
marginBottom: "4px",
}}
>
Command
</label>
<textarea
value={form.command}
onChange={(e) =>
setForm((prev) => ({ ...prev, command: e.target.value }))
}
placeholder="e.g. npm run build"
rows={3}
style={{
width: "100%",
padding: "8px 12px",
borderRadius: "6px",
border: "1px solid var(--border-primary)",
background: "var(--bg-primary)",
color: "var(--text-primary)",
fontSize: "13px",
fontFamily: "monospace",
resize: "vertical",
}}
data-testid="script-command-input"
/>
</div>
{/* Form actions */}
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: "8px",
marginTop: "4px",
}}
>
<button className="btn btn-secondary" onClick={handleCancel} disabled={saving}>
Cancel
</button>
<button
className="btn btn-primary"
onClick={handleSave}
disabled={saving || !form.name.trim() || !form.command.trim()}
data-testid="save-script-btn"
>
{saving ? "Saving..." : isCreating ? "Create" : "Save"}
</button>
</div>
</div>
</div>
)}
</>
)}
</div>
{/* Footer */}
{!isEditing && (
<div
className="modal-footer"
style={{ padding: "12px 16px", borderTop: "1px solid var(--border-primary)" }}
>
<button
className="btn btn-primary"
onClick={handleCreate}
style={{ display: "flex", alignItems: "center", gap: "6px" }}
data-testid="add-script-btn"
>
<Plus size={14} />
Add Script
</button>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,332 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { ScriptsModal } from "../ScriptsModal";
const mockScripts: Record<string, string> = {
build: "npm run build",
test: "pnpm test",
lint: "eslint src/",
};
vi.mock("../../api", () => ({
fetchScripts: vi.fn(() => Promise.resolve({})),
addScript: vi.fn(() => Promise.resolve({})),
removeScript: vi.fn(() => Promise.resolve({})),
}));
import { fetchScripts, addScript, removeScript } from "../../api";
const onClose = vi.fn();
const addToast = vi.fn();
const onRunScript = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
describe("ScriptsModal", () => {
it("does not render when closed", () => {
const { container } = render(
<ScriptsModal isOpen={false} onClose={onClose} addToast={addToast} />
);
expect(container.innerHTML).toBe("");
});
it("renders list of scripts", async () => {
vi.mocked(fetchScripts).mockResolvedValueOnce(mockScripts);
render(<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("build")).toBeInTheDocument();
expect(screen.getByText("test")).toBeInTheDocument();
expect(screen.getByText("lint")).toBeInTheDocument();
});
});
it("shows empty state when no scripts exist", async () => {
vi.mocked(fetchScripts).mockResolvedValueOnce({});
render(<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByTestId("scripts-empty-state")).toBeInTheDocument();
expect(screen.getByText(/No scripts defined yet/)).toBeInTheDocument();
});
});
it("opens create form when Add button is clicked", async () => {
vi.mocked(fetchScripts).mockResolvedValueOnce({});
render(<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByTestId("scripts-empty-state")).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId("add-script-btn"));
await waitFor(() => {
expect(screen.getByTestId("script-form")).toBeInTheDocument();
expect(screen.getByText("New Script")).toBeInTheDocument();
});
});
it("validates script name - rejects empty name (button disabled)", async () => {
vi.mocked(fetchScripts).mockResolvedValueOnce({});
render(<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByTestId("scripts-empty-state")).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId("add-script-btn"));
await waitFor(() => {
expect(screen.getByTestId("script-form")).toBeInTheDocument();
});
// Fill in command only
fireEvent.change(screen.getByTestId("script-command-input"), {
target: { value: "echo hello" },
});
// Save button should be disabled when name is empty
const saveButton = screen.getByTestId("save-script-btn") as HTMLButtonElement;
expect(saveButton.disabled).toBe(true);
});
it("validates script name - rejects invalid characters", async () => {
vi.mocked(fetchScripts).mockResolvedValueOnce({});
render(<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByTestId("scripts-empty-state")).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId("add-script-btn"));
await waitFor(() => {
expect(screen.getByTestId("script-form")).toBeInTheDocument();
});
// Fill in name with spaces
fireEvent.change(screen.getByTestId("script-name-input"), {
target: { value: "my script" },
});
fireEvent.change(screen.getByTestId("script-command-input"), {
target: { value: "echo hello" },
});
// Click save
fireEvent.click(screen.getByTestId("save-script-btn"));
await waitFor(() => {
expect(screen.getByTestId("script-validation-error")).toBeInTheDocument();
expect(
screen.getByText(/Name must be alphanumeric with hyphens and underscores only/)
).toBeInTheDocument();
});
});
it("validates script name - rejects reserved names", async () => {
vi.mocked(fetchScripts).mockResolvedValueOnce({});
render(<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByTestId("scripts-empty-state")).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId("add-script-btn"));
await waitFor(() => {
expect(screen.getByTestId("script-form")).toBeInTheDocument();
});
// Fill in reserved name
fireEvent.change(screen.getByTestId("script-name-input"), {
target: { value: "run" },
});
fireEvent.change(screen.getByTestId("script-command-input"), {
target: { value: "echo hello" },
});
// Click save
fireEvent.click(screen.getByTestId("save-script-btn"));
await waitFor(() => {
expect(screen.getByTestId("script-validation-error")).toBeInTheDocument();
expect(screen.getByText(/Script name 'run' is reserved/)).toBeInTheDocument();
});
});
it("creates a new script successfully", async () => {
vi.mocked(fetchScripts).mockResolvedValueOnce({});
vi.mocked(addScript).mockResolvedValueOnce({ deploy: "npm run deploy" });
render(<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByTestId("scripts-empty-state")).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId("add-script-btn"));
await waitFor(() => {
expect(screen.getByTestId("script-form")).toBeInTheDocument();
});
// Fill in form
fireEvent.change(screen.getByTestId("script-name-input"), {
target: { value: "deploy" },
});
fireEvent.change(screen.getByTestId("script-command-input"), {
target: { value: "npm run deploy" },
});
// Click save
fireEvent.click(screen.getByTestId("save-script-btn"));
await waitFor(() => {
expect(addScript).toHaveBeenCalledWith("deploy", "npm run deploy");
expect(addToast).toHaveBeenCalledWith("Script 'deploy' created", "success");
});
});
it("runs a script when run button is clicked", async () => {
vi.mocked(fetchScripts).mockResolvedValueOnce(mockScripts);
render(
<ScriptsModal
isOpen={true}
onClose={onClose}
addToast={addToast}
onRunScript={onRunScript}
/>
);
await waitFor(() => {
expect(screen.getByText("build")).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId("run-script-build"));
await waitFor(() => {
expect(onRunScript).toHaveBeenCalledWith("build", "npm run build");
});
});
it("deletes a script with confirmation", async () => {
vi.mocked(fetchScripts).mockResolvedValueOnce(mockScripts);
vi.mocked(removeScript).mockResolvedValueOnce({ test: "pnpm test", lint: "eslint src/" });
render(<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("build")).toBeInTheDocument();
});
// Click delete button
fireEvent.click(screen.getByTestId("delete-script-build"));
// Should show confirm/cancel buttons
await waitFor(() => {
expect(screen.getByTitle("Confirm delete")).toBeInTheDocument();
expect(screen.getByTitle("Cancel delete")).toBeInTheDocument();
});
// Click confirm
fireEvent.click(screen.getByTitle("Confirm delete"));
await waitFor(() => {
expect(removeScript).toHaveBeenCalledWith("build");
expect(addToast).toHaveBeenCalledWith("Script 'build' deleted", "success");
});
});
it("cancels delete when cancel button is clicked", async () => {
vi.mocked(fetchScripts).mockResolvedValueOnce(mockScripts);
render(<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("build")).toBeInTheDocument();
});
// Click delete button
fireEvent.click(screen.getByTestId("delete-script-build"));
// Should show confirm/cancel buttons
await waitFor(() => {
expect(screen.getByTitle("Cancel delete")).toBeInTheDocument();
});
// Click cancel
fireEvent.click(screen.getByTitle("Cancel delete"));
// Delete should not have been called
expect(removeScript).not.toHaveBeenCalled();
});
it("handles API errors gracefully", async () => {
vi.mocked(fetchScripts).mockRejectedValueOnce(new Error("Failed to fetch"));
render(<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Failed to fetch", "error");
});
});
it("shows loading state while fetching scripts", async () => {
vi.mocked(fetchScripts).mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve({}), 100))
);
render(<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} />);
expect(screen.getByTestId("scripts-loading")).toBeInTheDocument();
});
it("edits an existing script", async () => {
vi.mocked(fetchScripts).mockResolvedValueOnce(mockScripts);
vi.mocked(addScript).mockResolvedValueOnce({ build: "npm run build:prod", test: "pnpm test" });
render(<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("build")).toBeInTheDocument();
});
// Click edit button
fireEvent.click(screen.getByTestId("edit-script-build"));
await waitFor(() => {
expect(screen.getByTestId("script-form")).toBeInTheDocument();
expect(screen.getByText("Edit Script")).toBeInTheDocument();
});
// Name should be disabled for editing
const nameInput = screen.getByTestId("script-name-input") as HTMLInputElement;
expect(nameInput.disabled).toBe(true);
// Change command
fireEvent.change(screen.getByTestId("script-command-input"), {
target: { value: "npm run build:prod" },
});
// Click save
fireEvent.click(screen.getByTestId("save-script-btn"));
await waitFor(() => {
expect(addScript).toHaveBeenCalledWith("build", "npm run build:prod");
expect(addToast).toHaveBeenCalledWith("Script 'build' updated", "success");
});
});
});

View File

@@ -5691,6 +5691,187 @@ Output ONLY the prompt text (no markdown, no explanations).`;
}
});
// ── Scripts Routes ─────────────────────────────────────────────────────────
/**
* GET /api/scripts
* Returns all project-defined scripts from settings.
* Response: Record<string, string>
*/
router.get("/scripts", async (_req, res) => {
try {
const settings = await store.getSettings();
res.json(settings.scripts || {});
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* POST /api/scripts
* Add or update a script.
* Body: { name: string, command: string }
* Validates name (alphanumeric, hyphens, underscores only, no spaces).
* Returns: Record<string, string> (updated scripts)
*/
router.post("/scripts", async (req, res) => {
try {
const { name, command } = req.body;
// Validate name
if (!name || typeof name !== "string" || !name.trim()) {
res.status(400).json({ error: "name is required" });
return;
}
if (!command || typeof command !== "string" || !command.trim()) {
res.status(400).json({ error: "command is required" });
return;
}
const trimmedName = name.trim();
const trimmedCommand = command.trim();
// Validate script name format (alphanumeric, hyphens, underscores only)
if (!/^[a-zA-Z0-9_-]+$/.test(trimmedName)) {
res.status(400).json({
error: "Script name must be alphanumeric with hyphens and underscores only (no spaces)",
});
return;
}
// Check for reserved/conflicting names
const reservedNames = ["run", "list", "add", "remove", "delete", "help"];
if (reservedNames.includes(trimmedName.toLowerCase())) {
res.status(400).json({ error: `Script name '${trimmedName}' is reserved` });
return;
}
const settings = await store.getSettings();
const currentScripts = settings.scripts || {};
// Check if script already exists (for conflict detection)
const exists = trimmedName in currentScripts;
// Update scripts
const updatedScripts = {
...currentScripts,
[trimmedName]: trimmedCommand,
};
await store.updateSettings({ scripts: updatedScripts });
res.status(exists ? 200 : 201).json(updatedScripts);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* DELETE /api/scripts/:name
* Remove a script by name.
* Returns: Record<string, string> (updated scripts)
*/
router.delete("/scripts/:name", async (req, res) => {
try {
const { name } = req.params;
if (!name || !name.trim()) {
res.status(400).json({ error: "Script name is required" });
return;
}
const settings = await store.getSettings();
const currentScripts = settings.scripts || {};
if (!(name in currentScripts)) {
res.status(404).json({ error: `Script '${name}' not found` });
return;
}
// Remove the script
const { [name]: _removed, ...remainingScripts } = currentScripts;
await store.updateSettings({ scripts: remainingScripts });
res.json(remainingScripts);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* POST /api/scripts/:name/run
* Execute a script with optional args.
* Body: { args?: string[] }
* Returns: { output: string; exitCode: number }
*/
router.post("/scripts/:name/run", async (req, res) => {
try {
const { name } = req.params;
const { args } = req.body;
if (!name || !name.trim()) {
res.status(400).json({ error: "Script name is required" });
return;
}
// Validate args if provided
if (args !== undefined && !Array.isArray(args)) {
res.status(400).json({ error: "args must be an array of strings" });
return;
}
if (args && args.some((arg: unknown) => typeof arg !== "string")) {
res.status(400).json({ error: "args must be an array of strings" });
return;
}
const settings = await store.getSettings();
const scripts = settings.scripts || {};
const command = scripts[name];
if (!command) {
res.status(404).json({ error: `Script '${name}' not found` });
return;
}
// Build the full command with args
const sanitizedArgs = (args || [])
.map((arg: string) => arg.replace(/["\\]/g, "\\$&"))
.join(" ");
const fullCommand = sanitizedArgs ? `${command} ${sanitizedArgs}` : command;
// Execute the command using terminal service or execSync
const rootDir = store.getRootDir();
let output: string;
let exitCode: number;
try {
// Use execSync for synchronous execution
output = execSync(fullCommand, {
encoding: "utf-8",
timeout: 300000, // 5 minute timeout
cwd: rootDir,
stdio: ["pipe", "pipe", "pipe"],
});
exitCode = 0;
} catch (execErr: any) {
// Command failed or timed out
output = execErr.stdout || "";
if (execErr.stderr) {
output += (output ? "\n" : "") + execErr.stderr;
}
if (execErr.message && !execErr.stderr) {
output += (output ? "\n" : "") + execErr.message;
}
exitCode = execErr.status || 1;
}
res.json({ output: output.trim(), exitCode });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// ── Agent Routes ───────────────────────────────────────────────────────────
/**