feat(KB-643): add dashboard scripts management and execution
- Add project settings support and dashboard API helpers for custom scripts - Add scripts CRUD and run routes backed by terminal sessions with validation and duplicate checks - Add Scripts modal and header integration for managing and launching project scripts - Add regression coverage for scripts routes and API client helpers
This commit is contained in:
@@ -5968,6 +5968,22 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
|
||||
// ── Scripts Routes ─────────────────────────────────────────────────────────
|
||||
|
||||
const SCRIPT_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
||||
const RESERVED_SCRIPT_NAMES = new Set(["run", "list", "add", "remove", "delete", "help"]);
|
||||
|
||||
function validateScriptName(name: string, label = "Script name"): string | null {
|
||||
if (!name.trim()) {
|
||||
return `${label} is required`;
|
||||
}
|
||||
if (!SCRIPT_NAME_PATTERN.test(name)) {
|
||||
return `${label} must be alphanumeric with hyphens and underscores only (no spaces)`;
|
||||
}
|
||||
if (label === "Script name" && RESERVED_SCRIPT_NAMES.has(name.toLowerCase())) {
|
||||
return `Script name '${name}' is reserved`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/scripts
|
||||
* Returns all project-defined scripts from settings.
|
||||
@@ -5984,16 +6000,15 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
|
||||
/**
|
||||
* POST /api/scripts
|
||||
* Add or update a script.
|
||||
* Create a new 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;
|
||||
const { name, command } = req.body ?? {};
|
||||
|
||||
// Validate name
|
||||
if (!name || typeof name !== "string" || !name.trim()) {
|
||||
res.status(400).json({ error: "name is required" });
|
||||
return;
|
||||
@@ -6005,29 +6020,20 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
|
||||
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` });
|
||||
const nameValidationError = validateScriptName(trimmedName);
|
||||
if (nameValidationError) {
|
||||
res.status(400).json({ error: nameValidationError });
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = await store.getSettings();
|
||||
const currentScripts = settings.scripts || {};
|
||||
|
||||
// Check if script already exists (for conflict detection)
|
||||
const exists = trimmedName in currentScripts;
|
||||
if (trimmedName in currentScripts) {
|
||||
res.status(409).json({ error: `Script '${trimmedName}' already exists` });
|
||||
return;
|
||||
}
|
||||
|
||||
// Update scripts
|
||||
const updatedScripts = {
|
||||
...currentScripts,
|
||||
[trimmedName]: trimmedCommand,
|
||||
@@ -6035,7 +6041,7 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
|
||||
await store.updateSettings({ scripts: updatedScripts });
|
||||
|
||||
res.status(exists ? 200 : 201).json(updatedScripts);
|
||||
res.status(201).json(updatedScripts);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
@@ -6055,16 +6061,23 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedName = name.trim();
|
||||
const nameValidationError = validateScriptName(trimmedName);
|
||||
if (nameValidationError) {
|
||||
res.status(400).json({ error: nameValidationError });
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = await store.getSettings();
|
||||
const currentScripts = settings.scripts || {};
|
||||
|
||||
if (!(name in currentScripts)) {
|
||||
res.status(404).json({ error: `Script '${name}' not found` });
|
||||
if (!(trimmedName in currentScripts)) {
|
||||
res.status(404).json({ error: `Script '${trimmedName}' not found` });
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove the script
|
||||
const { [name]: _removed, ...remainingScripts } = currentScripts;
|
||||
const { [trimmedName]: _removed, ...remainingScripts } = currentScripts;
|
||||
|
||||
await store.updateSettings({ scripts: remainingScripts });
|
||||
|
||||
@@ -6076,21 +6089,27 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
|
||||
/**
|
||||
* POST /api/scripts/:name/run
|
||||
* Execute a script with optional args.
|
||||
* Execute a project script by creating a terminal session for the resolved command.
|
||||
* Body: { args?: string[] }
|
||||
* Returns: { output: string; exitCode: number }
|
||||
* Returns: { command: string; sessionId: string }
|
||||
*/
|
||||
router.post("/scripts/:name/run", async (req, res) => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
const { args } = req.body;
|
||||
const { args } = req.body ?? {};
|
||||
|
||||
if (!name || !name.trim()) {
|
||||
res.status(400).json({ error: "Script name is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate args if provided
|
||||
const trimmedName = name.trim();
|
||||
const nameValidationError = validateScriptName(trimmedName);
|
||||
if (nameValidationError) {
|
||||
res.status(400).json({ error: nameValidationError });
|
||||
return;
|
||||
}
|
||||
|
||||
if (args !== undefined && !Array.isArray(args)) {
|
||||
res.status(400).json({ error: "args must be an array of strings" });
|
||||
return;
|
||||
@@ -6102,46 +6121,34 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
|
||||
const settings = await store.getSettings();
|
||||
const scripts = settings.scripts || {};
|
||||
const command = scripts[name];
|
||||
const command = scripts[trimmedName];
|
||||
|
||||
if (!command) {
|
||||
res.status(404).json({ error: `Script '${name}' not found` });
|
||||
res.status(404).json({ error: `Script '${trimmedName}' 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;
|
||||
const quotedArgs = (args || []).map((arg: string) => JSON.stringify(arg)).join(" ");
|
||||
const fullCommand = quotedArgs ? `${command} ${quotedArgs}` : command;
|
||||
|
||||
// Execute the command using terminal service or execSync
|
||||
const rootDir = store.getRootDir();
|
||||
let output: string;
|
||||
let exitCode: number;
|
||||
const terminalService = getTerminalService(rootDir);
|
||||
const ptySession = await terminalService.createSession({ cwd: rootDir });
|
||||
|
||||
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;
|
||||
if (!ptySession.success) {
|
||||
const statusByCode = {
|
||||
max_sessions: 503,
|
||||
invalid_shell: 400,
|
||||
pty_load_failed: 503,
|
||||
pty_spawn_failed: 500,
|
||||
} as const;
|
||||
res.status(statusByCode[ptySession.code]).json({ error: ptySession.error });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ output: output.trim(), exitCode });
|
||||
terminalService.write(ptySession.session.id, `${fullCommand}\n`);
|
||||
|
||||
res.status(201).json({ command: fullCommand, sessionId: ptySession.session.id });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
|
||||
320
packages/dashboard/src/scripts-routes.routes.test.ts
Normal file
320
packages/dashboard/src/scripts-routes.routes.test.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import express from "express";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
import { createApiRoutes } from "./routes.js";
|
||||
import { request as performRequest, get as performGet } from "./test-request.js";
|
||||
|
||||
function createMockGlobalSettingsStore() {
|
||||
return {
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
updateSettings: vi.fn().mockResolvedValue({}),
|
||||
getSettingsPath: vi.fn().mockReturnValue("/fake/home/.pi/kb/settings.json"),
|
||||
init: vi.fn().mockResolvedValue(false),
|
||||
};
|
||||
}
|
||||
|
||||
function createMockMissionStore() {
|
||||
return {
|
||||
createSession: vi.fn().mockResolvedValue({ id: "session-1", status: "active" }),
|
||||
getSession: vi.fn().mockResolvedValue({ id: "session-1", status: "active", answers: [] }),
|
||||
updateSession: vi.fn().mockResolvedValue(undefined),
|
||||
addAnswer: vi.fn().mockResolvedValue(undefined),
|
||||
deleteSession: vi.fn().mockResolvedValue(undefined),
|
||||
listSessions: vi.fn().mockResolvedValue([]),
|
||||
generatePlan: vi.fn().mockResolvedValue({ plan: "Test plan", steps: [] }),
|
||||
};
|
||||
}
|
||||
|
||||
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
return {
|
||||
getTask: vi.fn(),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
createTask: vi.fn(),
|
||||
moveTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
updateSettings: vi.fn(),
|
||||
updateGlobalSettings: vi.fn(),
|
||||
getSettingsByScope: vi.fn().mockResolvedValue({ global: {}, project: {} }),
|
||||
getGlobalSettingsStore: vi.fn().mockReturnValue(createMockGlobalSettingsStore()),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
getAgentLogs: vi.fn().mockResolvedValue([]),
|
||||
addComment: vi.fn(),
|
||||
addTaskComment: vi.fn(),
|
||||
updateTaskComment: vi.fn(),
|
||||
deleteTaskComment: vi.fn(),
|
||||
updatePrInfo: vi.fn().mockResolvedValue(undefined),
|
||||
updateIssueInfo: vi.fn().mockResolvedValue(undefined),
|
||||
getRootDir: vi.fn().mockReturnValue("/fake/root"),
|
||||
listWorkflowSteps: vi.fn().mockResolvedValue([]),
|
||||
createWorkflowStep: vi.fn(),
|
||||
getWorkflowStep: vi.fn(),
|
||||
updateWorkflowStep: vi.fn(),
|
||||
deleteWorkflowStep: vi.fn(),
|
||||
getMissionStore: vi.fn().mockReturnValue(createMockMissionStore()),
|
||||
...overrides,
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
|
||||
async function GET(app: express.Express, path: string): Promise<{ status: number; body: any }> {
|
||||
const res = await performGet(app, path);
|
||||
return { status: res.status, body: res.body };
|
||||
}
|
||||
|
||||
async function REQUEST(
|
||||
app: express.Express,
|
||||
method: string,
|
||||
path: string,
|
||||
body?: Buffer | string,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<{ status: number; body: any }> {
|
||||
const res = await performRequest(app, method, path, body, headers);
|
||||
return { status: res.status, body: res.body };
|
||||
}
|
||||
|
||||
describe("Scripts routes", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore();
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("returns all scripts from project settings", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
scripts: { build: "pnpm build", test: "pnpm test" },
|
||||
});
|
||||
|
||||
const res = await GET(buildApp(), "/api/scripts");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ build: "pnpm build", test: "pnpm test" });
|
||||
});
|
||||
|
||||
it("creates a new script and returns 201", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ scripts: { test: "pnpm test" } });
|
||||
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce(undefined);
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/scripts",
|
||||
JSON.stringify({ name: "build", command: "pnpm build" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(store.updateSettings).toHaveBeenCalledWith({
|
||||
scripts: { test: "pnpm test", build: "pnpm build" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 409 when creating a duplicate script", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ scripts: { build: "pnpm build" } });
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/scripts",
|
||||
JSON.stringify({ name: "build", command: "pnpm build --filter app" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toContain("already exists");
|
||||
expect(store.updateSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 for invalid script names", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/scripts",
|
||||
JSON.stringify({ name: "bad name", command: "echo hi" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("alphanumeric");
|
||||
});
|
||||
|
||||
it("returns 400 for reserved script names", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/scripts",
|
||||
JSON.stringify({ name: "run", command: "echo hi" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("reserved");
|
||||
});
|
||||
|
||||
it("returns 400 when command is missing", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/scripts",
|
||||
JSON.stringify({ name: "build", command: " " }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("command is required");
|
||||
});
|
||||
|
||||
|
||||
it("deletes an existing script and persists remaining scripts", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
scripts: { build: "pnpm build", test: "pnpm test" },
|
||||
});
|
||||
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce(undefined);
|
||||
|
||||
const res = await REQUEST(buildApp(), "DELETE", "/api/scripts/build");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ test: "pnpm test" });
|
||||
expect(store.updateSettings).toHaveBeenCalledWith({ scripts: { test: "pnpm test" } });
|
||||
});
|
||||
|
||||
it("returns 400 when deleting an invalid script name", async () => {
|
||||
const res = await REQUEST(buildApp(), "DELETE", "/api/scripts/bad%20name");
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("alphanumeric");
|
||||
});
|
||||
|
||||
it("returns 404 when deleting a missing script", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ scripts: { test: "pnpm test" } });
|
||||
|
||||
const res = await REQUEST(buildApp(), "DELETE", "/api/scripts/build");
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toContain("not found");
|
||||
});
|
||||
|
||||
it("returns 400 when running an invalid script name", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/scripts/bad%20name/run",
|
||||
JSON.stringify({ args: [] }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("alphanumeric");
|
||||
});
|
||||
|
||||
it("returns 404 when running a missing script", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ scripts: { test: "pnpm test" } });
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/scripts/build/run",
|
||||
JSON.stringify({ args: [] }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toContain("not found");
|
||||
});
|
||||
|
||||
it("returns 400 when run args are not an array", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ scripts: { test: "pnpm test" } });
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/scripts/test/run",
|
||||
JSON.stringify({ args: "--ok" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("array of strings");
|
||||
});
|
||||
|
||||
it("returns 400 when run args are not an array of strings", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ scripts: { test: "pnpm test" } });
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/scripts/test/run",
|
||||
JSON.stringify({ args: ["--ok", 123] }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("array of strings");
|
||||
});
|
||||
|
||||
it("returns terminal service errors when session creation fails", async () => {
|
||||
const createSessionSpy = vi
|
||||
.spyOn(await import("./terminal-service.js"), "getTerminalService")
|
||||
.mockReturnValue({
|
||||
createSession: vi.fn().mockResolvedValue({
|
||||
success: false,
|
||||
code: "max_sessions",
|
||||
error: "Maximum terminal sessions reached",
|
||||
}),
|
||||
} as any);
|
||||
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ scripts: { test: "pnpm test" } });
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/scripts/test/run",
|
||||
JSON.stringify({ args: [] }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.error).toContain("Maximum terminal sessions reached");
|
||||
createSessionSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("creates a terminal session in the project root when running a script", async () => {
|
||||
const writeInput = vi.fn();
|
||||
const createSession = vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
session: { id: "pty-123", cwd: "/fake/root", shell: "/bin/zsh" },
|
||||
});
|
||||
const terminalServiceSpy = vi
|
||||
.spyOn(await import("./terminal-service.js"), "getTerminalService")
|
||||
.mockReturnValue({ createSession, writeInput } as any);
|
||||
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ scripts: { test: "pnpm test" } });
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/scripts/test/run",
|
||||
JSON.stringify({ args: ["--filter", "web app; rm -rf /"] }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.sessionId).toBe("pty-123");
|
||||
expect(res.body.command).toBe('pnpm test "--filter" "web app; rm -rf /"');
|
||||
expect(createSession).toHaveBeenCalledWith({ cwd: "/fake/root" });
|
||||
expect(writeInput).toHaveBeenCalledWith("pty-123", 'pnpm test "--filter" "web app; rm -rf /"\n');
|
||||
terminalServiceSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user