feat(KB-502): implement dashboard multi-project UX

- Add project selector, health badge, and overview components to dashboard
- Add multi-project API functions (api.ts) for cross-project operations
- Update App.tsx with project context and navigation improvements
- Add script-store.ts for centralized project script management
- Extend types with multi-project support: SetupState, MigrationOptions, etc.
- Add project runtime fields: baseCommitSha, modifiedFiles, missionId, sliceId
- Add setupComplete to global settings for first-run wizard tracking
- Add project-level settings: scripts, setupScript, ntfyDashboardHost
- Add backward-compatible addSteeringComment method to TaskStore
This commit is contained in:
gsxdsm
2026-04-01 16:41:11 -07:00
parent c4b611605e
commit dac68ab2f7
9 changed files with 647 additions and 94 deletions

View File

@@ -1,7 +1,7 @@
import { useState, useCallback, useEffect } from "react";
import type { TaskDetail, TaskCreateInput, Task, ThemeMode, ProjectInfo } from "@fusion/core";
import { fetchConfig, fetchSettings, fetchAuthStatus, updateSettings, fetchModels, fetchTaskDetail } from "./api";
import type { ModelInfo } from "./api";
import type { TaskDetail, TaskCreateInput, Task, ThemeMode } from "@fusion/core";
import { fetchConfig, fetchSettings, fetchAuthStatus, updateSettings, fetchModels, fetchTaskDetail, updateProject, unregisterProject } from "./api";
import type { ModelInfo, ProjectInfo } from "./api";
import { Header } from "./components/Header";
import { Board } from "./components/Board";
import { ListView } from "./components/ListView";
@@ -25,13 +25,49 @@ 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";
import { ToastProvider, useToast } from "./hooks/useToast";
import { useTheme } from "./hooks/useTheme";
type ViewMode = "overview" | "project";
type TaskView = "board" | "list" | "agents";
function AppInner() {
const { toasts, addToast, removeToast } = useToast();
// Project management hooks - MUST be called before any conditional logic
const { projects, loading: projectsLoading, error: projectsError, refresh: refreshProjects, register: registerProject, update: updateProjectHook, unregister: unregisterProjectHook } = useProjects();
const { currentProject, setCurrentProject, clearCurrentProject, loading: currentProjectLoading } = useCurrentProject(projects);
// Tasks hook with project context
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask, archiveTask, unarchiveTask, archiveAllDone } = useTasks(
currentProject ? { projectId: currentProject.id } : undefined
);
// Theme management
const { themeMode, colorTheme, setThemeMode, setColorTheme } = useTheme();
// View state
const [viewMode, setViewMode] = useState<ViewMode>(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem("kb-dashboard-view-mode");
if (saved === "overview" || saved === "project") return saved;
}
return "overview";
});
const [taskView, setTaskView] = useState<TaskView>(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem("kb-dashboard-task-view");
if (saved === "board" || saved === "list" || saved === "agents") return saved;
}
return "board";
});
// Modal states
const [newTaskModalOpen, setNewTaskModalOpen] = useState(false);
const [isPlanningOpen, setIsPlanningOpen] = useState(false);
const [planningInitialPlan, setPlanningInitialPlan] = useState<string | null>(null);
@@ -53,36 +89,38 @@ function AppInner() {
const [scriptsOpen, setScriptsOpen] = useState(false);
const [terminalInitialCommand, setTerminalInitialCommand] = useState<string | undefined>(undefined);
const [settingsInitialSection, setSettingsInitialSection] = useState<SectionId | undefined>(undefined);
const [setupWizardOpen, setSetupWizardOpen] = useState(false);
// Settings state
const [maxConcurrent, setMaxConcurrent] = useState(2);
const [rootDir, setRootDir] = useState<string>(".");
const [autoMerge, setAutoMerge] = useState(true);
const [globalPaused, setGlobalPaused] = useState(false);
const [enginePaused, setEnginePaused] = useState(false);
const [view, setView] = useState<"board" | "list" | "agents">(() => {
// Initialize from localStorage if available
if (typeof window !== "undefined") {
const saved = localStorage.getItem("kb-dashboard-view");
if (saved === "list" || saved === "board" || saved === "agents") {
return saved;
}
}
return "board";
});
const [searchQuery, setSearchQuery] = useState("");
const [githubTokenConfigured, setGithubTokenConfigured] = useState(false);
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
// Setup wizard state
const [setupWizardOpen, setSetupWizardOpen] = useState(false);
// Persist view mode
useEffect(() => {
localStorage.setItem("kb-dashboard-view-mode", viewMode);
}, [viewMode]);
// Tasks hook with project context
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask, archiveTask, unarchiveTask, archiveAllDone } = useTasks(
currentProject ? { projectId: currentProject.id } : undefined
);
// Persist task view
useEffect(() => {
localStorage.setItem("kb-dashboard-task-view", taskView);
}, [taskView]);
// Theme management
const { themeMode, colorTheme, setThemeMode, setColorTheme } = useTheme();
// Sync view mode when current project is restored from localStorage
useEffect(() => {
// Wait for both loading states to complete before syncing
if (projectsLoading || currentProjectLoading) return;
// If we have a restored current project but viewMode is overview, sync to project view
if (currentProject && viewMode === "overview") {
setViewMode("project");
}
}, [projectsLoading, currentProjectLoading, currentProject, viewMode]);
// Auto-open setup wizard on first run (no projects)
useEffect(() => {
@@ -93,7 +131,6 @@ function AppInner() {
if (setupWizardOpen) return;
// Don't open if we have projects OR a saved current project
// (currentProject from localStorage means user was previously viewing a project)
if (projects.length > 0 || currentProject) return;
// Only open when truly no projects exist and no project is being restored
@@ -103,29 +140,6 @@ function AppInner() {
return () => clearTimeout(timer);
}, [projectsLoading, projects.length, currentProjectLoading, currentProject, setupWizardOpen]);
// Persist view mode
useEffect(() => {
localStorage.setItem("kb-dashboard-view-mode", viewMode);
}, [viewMode]);
// Sync view mode when current project is restored from localStorage
// This ensures that if the user refreshed while viewing a specific project,
// they return to project view instead of the overview
useEffect(() => {
// Wait for both loading states to complete before syncing
if (projectsLoading || currentProjectLoading) return;
// If we have a restored current project but viewMode is overview, sync to project view
if (currentProject && viewMode === "overview") {
setViewMode("project");
}
}, [projectsLoading, currentProjectLoading, currentProject]); // intentionally NOT depending on viewMode
// Persist task view
useEffect(() => {
localStorage.setItem("kb-dashboard-task-view", taskView);
}, [taskView]);
// Theme toggle handler: cycles Dark → Light → System → Dark
const handleToggleTheme = useCallback(() => {
const cycle: ThemeMode[] = ["dark", "light", "system"];
@@ -134,6 +148,7 @@ function AppInner() {
setThemeMode(nextMode);
}, [themeMode, setThemeMode]);
// Initial data fetch
useEffect(() => {
fetchConfig()
.then((cfg) => {
@@ -156,7 +171,7 @@ function AppInner() {
setSettingsInitialSection("authentication");
}
})
.catch(() => {/* fail silently — do not auto-open */});
.catch(() => {/* fail silently */});
}, []);
// Fetch available models
@@ -165,7 +180,6 @@ function AppInner() {
.then((models) => setAvailableModels(models))
.catch(() => {/* keep empty array on failure */});
}, []);
const { toasts, addToast, removeToast } = useToast();
// Handle deep link to task on mount
useEffect(() => {
@@ -173,8 +187,18 @@ function AppInner() {
const taskId = params.get("task");
if (!taskId) return;
const handleChangeView = useCallback((newView: "board" | "list" | "agents") => {
setView(newView);
fetchTaskDetail(taskId)
.then((detail) => {
setDetailTask(detail);
})
.catch(() => {
addToast(`Task ${taskId} not found`, "error");
});
}, [addToast]);
// View change handlers
const handleChangeTaskView = useCallback((newView: TaskView) => {
setTaskView(newView);
}, []);
// Project selection handlers
@@ -192,23 +216,33 @@ function AppInner() {
setSetupWizardOpen(true);
}, []);
const handleSetupComplete = useCallback((project: ProjectInfo) => {
setSetupWizardOpen(false);
setCurrentProject(project);
setViewMode("project");
addToast(`Project ${project.name} registered successfully`, "success");
refreshProjects();
}, [setCurrentProject, addToast, refreshProjects]);
const handlePauseProject = useCallback(async (project: ProjectInfo) => {
try {
await updateProject(project.id, { status: "paused" });
addToast(`Project ${project.name} paused`, "success");
refreshProjects();
} catch {
addToast(`Failed to pause project ${project.name}`, "error");
}
}, [updateProject, addToast]);
}, [addToast, refreshProjects]);
const handleResumeProject = useCallback(async (project: ProjectInfo) => {
try {
await updateProject(project.id, { status: "active" });
addToast(`Project ${project.name} resumed`, "success");
refreshProjects();
} catch {
addToast(`Failed to resume project ${project.name}`, "error");
}
}, [updateProject, addToast]);
}, [addToast, refreshProjects]);
const handleRemoveProject = useCallback(async (project: ProjectInfo) => {
try {
@@ -219,11 +253,13 @@ function AppInner() {
clearCurrentProject();
setViewMode("overview");
}
refreshProjects();
} catch {
addToast(`Failed to remove project ${project.name}`, "error");
}
}, [unregisterProject, currentProject, clearCurrentProject, addToast]);
}, [unregisterProject, currentProject, clearCurrentProject, addToast, refreshProjects]);
// Task handlers
const handleNewTaskOpen = useCallback(() => setNewTaskModalOpen(true), []);
const handleNewTaskClose = useCallback(() => setNewTaskModalOpen(false), []);
@@ -358,33 +394,44 @@ function AppInner() {
const handleOpenAgents = useCallback(() => setAgentsOpen(true), []);
const handleCloseAgents = useCallback(() => setAgentsOpen(false), []);
return (
<>
<Header
onOpenSettings={() => setSettingsOpen(true)}
onOpenGitHubImport={() => setGitHubImportOpen(true)}
onOpenPlanning={handlePlanningOpen}
onOpenUsage={handleOpenUsage}
onOpenActivityLog={handleOpenActivityLog}
onOpenSchedules={handleOpenSchedules}
onOpenGitManager={handleOpenGitManager}
onOpenWorkflowSteps={() => setWorkflowStepsOpen(true)}
onOpenAgents={handleOpenAgents}
onToggleTerminal={handleToggleTerminal}
onOpenFiles={handleOpenFiles}
filesOpen={filesOpen}
globalPaused={globalPaused}
enginePaused={enginePaused}
onToggleGlobalPause={handleToggleGlobalPause}
onToggleEnginePause={handleToggleEnginePause}
view={view}
onChangeView={handleChangeView}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
/>
{view === "agents" ? (
<AgentsView addToast={addToast} />
) : view === "board" ? (
// Scripts handlers
const handleOpenScripts = useCallback(() => setScriptsOpen(true), []);
const handleCloseScripts = useCallback(() => setScriptsOpen(false), []);
const handleRunScript = useCallback((name: string, command: string) => {
setTerminalInitialCommand(command);
setTerminalOpen(true);
addToast(`Running script: ${name}`, "info");
}, [addToast]);
// Terminal close handler
const handleTerminalClose = useCallback(() => {
setTerminalOpen(false);
setTerminalInitialCommand(undefined);
}, []);
// Render main content based on view mode
const renderMainContent = () => {
if (viewMode === "overview") {
return (
<ProjectOverview
projects={projects}
loading={projectsLoading}
onSelectProject={handleSelectProject}
onAddProject={handleAddProject}
onPauseProject={handlePauseProject}
onResumeProject={handleResumeProject}
onRemoveProject={handleRemoveProject}
/>
);
}
// Project view
if (taskView === "agents") {
return <AgentsView addToast={addToast} />;
}
if (taskView === "board") {
return (
<Board
tasks={tasks}
maxConcurrent={maxConcurrent}
@@ -448,7 +495,7 @@ function AppInner() {
onToggleGlobalPause={handleToggleGlobalPause}
onToggleEnginePause={handleToggleEnginePause}
view={taskView}
onChangeView={setTaskView}
onChangeView={handleChangeTaskView}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
projects={projects}

View File

@@ -1868,3 +1868,100 @@ export function fetchProjectTasks(projectId: string, limit?: number, offset?: nu
export function fetchProjectConfig(projectId: string): Promise<{ maxConcurrent: number; rootDir: string }> {
return api<{ maxConcurrent: number; rootDir: string }>(`/projects/${encodeURIComponent(projectId)}/config`);
}
/** Detected project information */
export interface DetectedProject {
path: string;
suggestedName: string;
existing: boolean;
}
/** Detect projects in a base path */
export function detectProjects(basePath?: string): Promise<{ projects: DetectedProject[] }> {
return api<{ projects: DetectedProject[] }>("/projects/detect", {
method: "POST",
body: JSON.stringify({ basePath }),
});
}
/** Fetch a single project by ID */
export function fetchProject(id: string): Promise<ProjectInfo> {
return api<ProjectInfo>(`/projects/${encodeURIComponent(id)}`);
}
/** Update an existing project */
export function updateProject(id: string, updates: Partial<ProjectInfo>): Promise<ProjectInfo> {
return api<ProjectInfo>(`/projects/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(updates),
});
}
// ── Scripts API ──────────────────────────────────────────────────────────
/** Fetch all saved 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 */
export function removeScript(name: string): Promise<Record<string, string>> {
return api<Record<string, string>>(`/scripts/${encodeURIComponent(name)}`, {
method: "DELETE",
});
}
// ── Task Diff API ──────────────────────────────────────────────────────────
/** Task diff information */
export interface TaskDiff {
files: Array<{
path: string;
status: "added" | "modified" | "deleted";
additions: number;
deletions: number;
patch: string;
}>;
stats: {
filesChanged: number;
additions: number;
deletions: number;
};
}
/** Fetch diff for a task's changes */
export function fetchTaskDiff(taskId: string, worktree?: string): Promise<TaskDiff> {
const params = new URLSearchParams();
if (worktree) params.set("worktree", worktree);
const query = params.size > 0 ? `?${params.toString()}` : "";
return api<TaskDiff>(`/tasks/${encodeURIComponent(taskId)}/diff${query}`);
}
/** Individual file diff */
export interface TaskFileDiff {
path: string;
status: "added" | "modified" | "deleted";
additions: number;
deletions: number;
patch: string;
}
/** Fetch file diffs for a task */
export function fetchTaskFileDiffs(taskId: string, worktree?: string): Promise<TaskFileDiff[]> {
const params = new URLSearchParams();
if (worktree) params.set("worktree", worktree);
const query = params.size > 0 ? `?${params.toString()}` : "";
return api<TaskFileDiff[]>(`/tasks/${encodeURIComponent(taskId)}/file-diffs${query}`);
}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Workflow, Bot, ChevronLeft } from "lucide-react";
import type { ProjectInfo } from "@fusion/core";
import type { ProjectInfo } from "../api";
import { ProjectSelector } from "./ProjectSelector";
import { QuickScriptsDropdown } from "./QuickScriptsDropdown";

View File

@@ -1,6 +1,7 @@
import { useState, useCallback } from "react";
import { Play, Pause, AlertCircle, Loader2 } from "lucide-react";
import type { ProjectStatus, ProjectHealth } from "@fusion/core";
import type { ProjectStatus } from "@fusion/core";
import type { ProjectHealth } from "../api";
export interface ProjectHealthBadgeProps {
status: ProjectStatus;

View File

@@ -1,6 +1,6 @@
import { useState, useMemo, useCallback, useEffect } from "react";
import { Plus, LayoutGrid, Filter, ArrowUpDown, Activity, CheckCircle, AlertCircle, Folder, Inbox } from "lucide-react";
import type { ProjectInfo, ProjectHealth, ProjectStatus } from "@fusion/core";
import type { ProjectInfo, ProjectHealth, ProjectStatus } from "../api";
import { ProjectCard } from "./ProjectCard";
import { ProjectGridSkeleton } from "./ProjectGridSkeleton";
import { useProjectHealth } from "../hooks/useProjectHealth";

View File

@@ -12,7 +12,7 @@ import {
Loader2,
X,
} from "lucide-react";
import type { ProjectInfo, ProjectStatus } from "@fusion/core";
import type { ProjectInfo, ProjectStatus } from "../api";
export interface ProjectSelectorProps {
projects: ProjectInfo[];

View File

@@ -5890,6 +5890,122 @@ Output ONLY the prompt text (no markdown, no explanations).`;
}
});
/**
* POST /api/projects/detect
* Auto-detect kb projects in a directory.
* Body: { basePath?: string }
* Returns: { projects: DetectedProject[] }
*/
router.post("/projects/detect", async (req, res) => {
try {
const { basePath } = req.body;
const { existsSync } = await import("node:fs");
const { join } = await import("node:path");
const { readdir } = await import("node:fs/promises");
// Default to home directory if no basePath provided
const searchPath = basePath || process.env.HOME || process.env.USERPROFILE || ".";
if (!existsSync(searchPath)) {
res.status(400).json({ error: "Base path does not exist" });
return;
}
// Get list of existing projects to check for duplicates
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const existingProjects = await central.listProjects();
await central.close();
const existingPaths = new Set(existingProjects.map((p: { path: string }) => p.path));
// Scan for .kb/kb.db or .fusion/kb.db files (indicating kb projects)
const detected: Array<{ path: string; suggestedName: string; existing: boolean }> = [];
try {
const entries = await readdir(searchPath, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const dirPath = join(searchPath, entry.name);
const hasKbDb = existsSync(join(dirPath, ".kb", "kb.db"));
const hasFusionDir = existsSync(join(dirPath, ".fusion"));
if (hasKbDb || hasFusionDir) {
detected.push({
path: dirPath,
suggestedName: entry.name,
existing: existingPaths.has(dirPath),
});
}
}
} catch {
// Ignore read errors
}
res.json({ projects: detected });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* GET /api/projects/:id
* Get a single project by ID.
*/
router.get("/projects/:id", async (req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const project = await central.getProject(req.params.id);
await central.close();
if (!project) {
res.status(404).json({ error: "Project not found" });
return;
}
res.json(project);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* PATCH /api/projects/:id
* Update a project.
*/
router.patch("/projects/:id", async (req, res) => {
try {
const { name, status, isolationMode } = req.body;
const updates: Partial<import("@fusion/core").RegisteredProject> = {};
if (name !== undefined) updates.name = name;
if (status !== undefined) updates.status = status as import("@fusion/core").ProjectStatus;
if (isolationMode !== undefined) updates.isolationMode = isolationMode as "in-process" | "child-process";
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const project = await central.updateProject(req.params.id, updates);
await central.close();
if (!project) {
res.status(404).json({ error: "Project not found" });
return;
}
res.json(project);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* DELETE /api/projects/:id
* Unregister a project.
@@ -6074,6 +6190,226 @@ Output ONLY the prompt text (no markdown, no explanations).`;
}
});
/**
* GET /api/tasks/:id/diff
* Fetch git diff for a task's changes.
* Query: ?worktree=path
* Returns: TaskDiff
*/
router.get("/tasks/:id/diff", async (req, res) => {
try {
const task = store.getTask(req.params.id);
if (!task) {
res.status(404).json({ error: "Task not found" });
return;
}
const worktree = typeof req.query.worktree === "string" ? req.query.worktree : undefined;
const cwd = worktree || store.getRootDir();
// Get the base commit - default to HEAD~1 if not provided
let baseCommit = "HEAD~1";
// Get the diff
const { execSync } = await import("node:child_process");
// Get list of changed files
const filesOutput = execSync(`git diff --name-status ${baseCommit}..HEAD`, {
encoding: "utf-8",
cwd,
timeout: 10000,
});
const files: Array<{
path: string;
status: "added" | "modified" | "deleted";
additions: number;
deletions: number;
patch: string;
}> = [];
for (const line of filesOutput.trim().split("\n")) {
if (!line.trim()) continue;
const parts = line.split("\t");
const statusCode = parts[0];
const filePath = parts[1];
let status: "added" | "modified" | "deleted";
if (statusCode.startsWith("A")) status = "added";
else if (statusCode.startsWith("D")) status = "deleted";
else status = "modified";
// Get patch for this file
let patch = "";
try {
patch = execSync(`git diff ${baseCommit}..HEAD -- "${filePath}"`, {
encoding: "utf-8",
cwd,
timeout: 10000,
});
} catch {
// Ignore errors for individual files
}
// Count additions/deletions
const additions = (patch.match(/^\+[^+]/gm) || []).length;
const deletions = (patch.match(/^-[^-]/gm) || []).length;
files.push({ path: filePath, status, additions, deletions, patch });
}
const stats = {
filesChanged: files.length,
additions: files.reduce((sum, f) => sum + f.additions, 0),
deletions: files.reduce((sum, f) => sum + f.deletions, 0),
};
res.json({ files, stats });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* GET /api/tasks/:id/file-diffs
* Fetch simplified file diffs for a task.
* Query: ?worktree=path
* Returns: TaskFileDiff[]
*/
router.get("/tasks/:id/file-diffs", async (req, res) => {
try {
const task = store.getTask(req.params.id);
if (!task) {
res.status(404).json({ error: "Task not found" });
return;
}
const worktree = typeof req.query.worktree === "string" ? req.query.worktree : undefined;
const cwd = worktree || store.getRootDir();
// Get the base commit
let baseCommit = "HEAD~1";
// Get the diff stat for file list
const { execSync } = await import("node:child_process");
const filesOutput = execSync(`git diff --name-status ${baseCommit}..HEAD`, {
encoding: "utf-8",
cwd,
timeout: 10000,
});
const files: Array<{
path: string;
status: "added" | "modified" | "deleted";
additions: number;
deletions: number;
patch: string;
}> = [];
for (const line of filesOutput.trim().split("\n")) {
if (!line.trim()) continue;
const parts = line.split("\t");
const statusCode = parts[0];
const filePath = parts[1];
let status: "added" | "modified" | "deleted";
if (statusCode.startsWith("A")) status = "added";
else if (statusCode.startsWith("D")) status = "deleted";
else status = "modified";
let patch = "";
try {
patch = execSync(`git diff ${baseCommit}..HEAD -- "${filePath}"`, {
encoding: "utf-8",
cwd,
timeout: 10000,
});
} catch {
// Ignore errors for individual files
}
const additions = (patch.match(/^\+[^+]/gm) || []).length;
const deletions = (patch.match(/^-[^-]/gm) || []).length;
files.push({ path: filePath, status, additions, deletions, patch });
}
res.json(files);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// ── Scripts API ──────────────────────────────────────────────────────────
/**
* GET /api/scripts
* Fetch all saved scripts.
* Returns: Record<string, string> (name -> command)
*/
router.get("/scripts", async (_req, res) => {
try {
const { loadScriptStore } = await import("./script-store.js");
const store = await loadScriptStore();
res.json(store.getScripts());
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* POST /api/scripts
* Add or update a script.
* Body: { name: string, command: string }
* Returns: Record<string, string> (updated scripts)
*/
router.post("/scripts", async (req, res) => {
try {
const { name, command } = req.body;
if (!name || typeof name !== "string" || !name.trim()) {
res.status(400).json({ error: "name is required" });
return;
}
if (command === undefined || typeof command !== "string") {
res.status(400).json({ error: "command is required" });
return;
}
const { loadScriptStore } = await import("./script-store.js");
const store = await loadScriptStore();
store.setScript(name.trim(), command.trim());
await store.save();
res.json(store.getScripts());
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* DELETE /api/scripts/:name
* Remove a script.
* Returns: Record<string, string> (updated scripts)
*/
router.delete("/scripts/:name", async (req, res) => {
try {
const { name } = req.params;
const { loadScriptStore } = await import("./script-store.js");
const store = await loadScriptStore();
store.removeScript(name);
await store.save();
res.json(store.getScripts());
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
return router;
}

View File

@@ -0,0 +1,74 @@
import { readFile, writeFile, access, mkdir } from "node:fs/promises";
import { join } from "node:path";
import { homedir } from "node:os";
const SCRIPTS_FILE = join(homedir(), ".pi", "fusion", "scripts.json");
interface ScriptsData {
scripts: Record<string, string>;
}
class ScriptStore {
private scripts: Record<string, string> = {};
private filePath: string;
constructor(filePath: string) {
this.filePath = filePath;
}
async load(): Promise<void> {
try {
await access(this.filePath);
const content = await readFile(this.filePath, "utf-8");
const data = JSON.parse(content) as ScriptsData;
this.scripts = data.scripts || {};
} catch {
// File doesn't exist or is invalid - start with empty scripts
this.scripts = {};
}
}
async save(): Promise<void> {
const dir = this.filePath.substring(0, this.filePath.lastIndexOf("/"));
try {
await access(dir);
} catch {
await mkdir(dir, { recursive: true });
}
const data: ScriptsData = { scripts: this.scripts };
await writeFile(this.filePath, JSON.stringify(data, null, 2), "utf-8");
}
getScripts(): Record<string, string> {
return { ...this.scripts };
}
getScript(name: string): string | undefined {
return this.scripts[name];
}
setScript(name: string, command: string): void {
this.scripts[name] = command;
}
removeScript(name: string): void {
delete this.scripts[name];
}
}
let storeInstance: ScriptStore | null = null;
export async function loadScriptStore(): Promise<ScriptStore> {
if (!storeInstance) {
storeInstance = new ScriptStore(SCRIPTS_FILE);
await storeInstance.load();
}
return storeInstance;
}
export function resetScriptStore(): void {
storeInstance = null;
}
export type { ScriptStore };