diff --git a/docs/superpowers/specs/2026-03-13-script-generator-phase2-design.md b/docs/superpowers/specs/2026-03-13-script-generator-phase2-design.md index 7f45c1b5..0008ee63 100644 --- a/docs/superpowers/specs/2026-03-13-script-generator-phase2-design.md +++ b/docs/superpowers/specs/2026-03-13-script-generator-phase2-design.md @@ -9,17 +9,21 @@ ## Goal -Build the Script Library frontend: a three-panel page where MSP engineers can browse PowerShell script templates by category, fill in parameters, get a live preview, and generate + copy/download the final script. +Build the Script Library frontend: a page where MSP engineers can browse PowerShell script templates by category, fill in parameters, get a live preview, and generate + copy/download the final script. --- ## Architecture -**Layout:** Top filter bar + two-column layout (C). Category tabs and search across the top, template list on the left, generator panel on the right. Follows the existing glassmorphism design system. +**Layout:** Top filter bar + two-column layout. Category tabs and search across the top, template list on the left, generator panel on the right. Follows the existing glassmorphism design system. -**State:** Zustand store (`scriptGeneratorStore`) chosen over a local hook because script generation will soon be embeddable in session execution (Script Output Node). A store allows any component anywhere in the tree to read/write generation state without prop drilling. Survives navigation. +**State:** Zustand store (`scriptGeneratorStore`) chosen over a local hook because script generation will soon be embeddable in session execution (Script Output Node). A store allows any component anywhere in the tree to read/write generation state without prop drilling. Survives navigation — stale state is intentional and supports Phase 3 embeddability. `reset()` is a public action exposed for Phase 3 callers (e.g. session execution context that needs to clear output between runs). Within Phase 2, it is never called automatically; `selectTemplate()` clears form/output state inline via its own atomic `set()` call without calling `reset()`. -**Preview:** Client-side lightweight substitution for live preview as the user types (`{{variable_name}}` replacement only). The real `POST /scripts/generate` endpoint is called on Generate — it applies filters (`as_secure_string`, `as_array`, `as_bool`), conditionals, and PowerShell-safe sanitization. +**Preview:** Client-side lightweight substitution for live preview as the user types (`{{key}}` replacement only, matching the backend template variable syntax). The real `POST /scripts/generate` endpoint is called on Generate — it applies filters, conditionals, and PowerShell-safe sanitization on the backend. + +**Security note:** The viewer restriction on Generate/Download is enforced frontend-only in Phase 2. The backend `POST /scripts/generate` endpoint uses only `get_current_active_user` with no role check. Adding a backend engineer role guard is deferred — this is a known limitation. + +**Search note:** The backend `?search=` parameter matches against `name`, `description`, and `slug`. Tags are filtered separately via `?tags=` (comma-separated). Phase 2 does not expose a tag filter UI — search covers name/description/slug only. --- @@ -36,16 +40,18 @@ interface ScriptGeneratorState { templates: ScriptTemplateListItem[]; selectedTemplate: ScriptTemplateDetail | null; searchQuery: string; - activeCategoryId: string | null; - isLoadingTemplates: boolean; + activeCategoryId: string | null; // null = "All"; used to derive slug for API calls + isLoadingTemplates: boolean; // drives skeleton in ScriptTemplateList only + isLoadingDetail: boolean; // drives spinner in ScriptGeneratorPanel only // Form - paramValues: Record; // keyed by variable_name - formErrors: Record; // keyed by variable_name + paramValues: Record; // keyed by ScriptParameter.key; booleans stored as 'true'/'false' + formErrors: Record; // keyed by ScriptParameter.key // Output generatedScript: string | null; generationId: string | null; + generationWarnings: string[]; isGenerating: boolean; generateError: string | null; @@ -55,7 +61,8 @@ interface ScriptGeneratorState { selectTemplate: (id: string) => Promise; setCategory: (id: string | null) => void; setSearch: (query: string) => void; - setParamValue: (variableName: string, value: string) => void; + setParamValue: (key: string, value: string) => void; + validate: () => boolean; generate: (sessionId?: string) => Promise; clearOutput: () => void; reset: () => void; @@ -64,10 +71,18 @@ interface ScriptGeneratorState { ### Behaviour notes -- `setCategory` and `setSearch` both call `loadTemplates()` after updating state — they compose (category + search both applied to the API call) -- `selectTemplate` fetches the full `ScriptTemplateDetail` (including `parameters_schema` and `script_template`), then calls `reset()` to clear previous form/output state -- `reset()` clears `paramValues`, `formErrors`, `generatedScript`, `generationId`, `generateError` — does not clear template selection or browsing state -- `generate()` validates required params client-side first (populates `formErrors`), then calls `POST /scripts/generate` +- `setCategory` updates `activeCategoryId`, then calls `loadTemplates()` +- `setSearch` updates `searchQuery`, then calls `loadTemplates()`. The component (not the store) debounces its call to `setSearch` — `setSearch` always calls `loadTemplates()` immediately on invocation +- `loadTemplates()` resolves the slug from the `categories` array (`categories.find(c => c.id === activeCategoryId)?.slug`) before sending `{ category_slug, search }` to the API. When `activeCategoryId` is `null` ("All"), `category_slug` is omitted from the request +- `selectTemplate(id)` workflow: + 1. Sets `isLoadingDetail: true` (does NOT touch `isLoadingTemplates`) + 2. Fetches `ScriptTemplateDetail` from the API + 3. In a single `set()` call: sets `selectedTemplate`, populates `paramValues` by converting each `parameter.default` to string (`null` → `''`, `true` → `'true'`, `false` → `'false'`, numbers → `String(n)`), clears `formErrors`, `generatedScript`, `generationId`, `generationWarnings`, `generateError`, sets `isLoadingDetail: false` + 4. The template list remains fully visible and interactive throughout +- `reset()` clears exactly: `paramValues`, `formErrors`, `generatedScript`, `generationId`, `generationWarnings`, `generateError`. Does NOT clear `selectedTemplate`, `categories`, `templates`, or any browsing state. Not called by `selectTemplate()` — that action handles its own inline clear. Exposed as a public store action for Phase 3 callers +- `validate()`: if `selectedTemplate` is `null`, returns `true` immediately (nothing to validate). Otherwise iterates `selectedTemplate.parameters_schema.parameters`, checks `required && !paramValues[key]` for each, writes errors to `formErrors` via `set()`, returns `false` if any required param is missing, `true` otherwise +- `generate(sessionId?)`: if `selectedTemplate` is `null`, returns immediately (no-op). Otherwise calls `validate()` first — if it returns `false`, stops (errors are already in store). If valid, sets `isGenerating: true`, clears `generateError`, calls `POST /scripts/generate`. On success: sets `generatedScript`/`generationId`/`generationWarnings`, sets `isGenerating: false`. On error: extracts `error.response?.data?.detail` (FastAPI detail string) or falls back to `'Failed to generate script'`, sets `generateError`, sets `isGenerating: false` +- On generate success: `generatedScript` = `response.script`, `generationId` = `response.id`, `generationWarnings` = `response.warnings` --- @@ -81,29 +96,53 @@ export interface ScriptCategoryResponse { name: string; slug: string; description: string | null; + icon: string | null; + sort_order: number; template_count: number; } export interface ScriptTemplateListItem { id: string; category_id: string; + team_id: string | null; name: string; + slug: string; description: string | null; - script_complexity: 'simple' | 'moderate' | 'complex'; - usage_count: number; tags: string[]; + complexity: 'beginner' | 'intermediate' | 'advanced'; // must match backend ScriptComplexity enum exactly + estimated_runtime: string | null; + requires_elevation: boolean; + requires_modules: string[]; + is_verified: boolean; + usage_count: number; +} + +export interface ScriptParameterOption { + value: string; + label: string; +} + +export interface ScriptParameterValidation { + min_value?: number; // matches backend field name (not 'min') + max_value?: number; // matches backend field name (not 'max') + pattern?: string; + min_length?: number; + max_length?: number; } export interface ScriptParameter { - variable_name: string; + key: string; label: string; - field_type: 'text' | 'password' | 'select' | 'multiselect' | 'checkbox' | 'number'; + type: 'text' | 'password' | 'select' | 'boolean' | 'multi_text' | 'number' | 'textarea'; required: boolean; - default_value?: string; - options?: string[]; - is_sensitive: boolean; - display_order: number; - help_text?: string; + placeholder: string | null; + group: string | null; + order: number; + help_text: string | null; + options: ScriptParameterOption[] | null; // for select type: [{value, label}] + default: string | boolean | number | null; + validation: ScriptParameterValidation | null; + sensitive: boolean; } export interface ScriptParametersSchema { @@ -111,20 +150,45 @@ export interface ScriptParametersSchema { } export interface ScriptTemplateDetail extends ScriptTemplateListItem { + use_case: string | null; + script_body: string; + // NOTE: backend types this as `dict` — FastAPI serializes it as plain JSON. + // At runtime this arrives as `unknown`. Access via a helper: + // function getParameters(detail: ScriptTemplateDetail): ScriptParameter[] { + // return ((detail.parameters_schema as ScriptParametersSchema)?.parameters ?? []) + // } parameters_schema: ScriptParametersSchema; - script_template: string; + default_values: Record; + validation_rules: Record; + version: number; + created_at: string; + updated_at: string; } export interface ScriptGenerateRequest { template_id: string; - parameters: Record; + parameters: Record; session_id?: string; } export interface ScriptGenerateResponse { - generation_id: string; - generated_script: string; + id: string; // generation UUID + script: string; // rendered PowerShell + warnings: string[]; + metadata: { + template_name: string; + template_version: number; + requires_elevation: boolean; + [key: string]: unknown; + }; +} + +export interface ScriptGenerationRecord { + id: string; + template_id: string; template_name: string; + parameters_used: Record; // sensitive values already redacted by backend + created_at: string; } ``` @@ -132,25 +196,34 @@ export interface ScriptGenerateResponse { ## API Client -**File:** `frontend/src/api/scripts.ts` +**File:** `frontend/src/api/scripts.ts` — use a named export object (matching the `copilotApi`/`assistantChatApi` pattern in `api/index.ts`): ```typescript -getCategories(): Promise -getTemplates(params?: { category_id?: string; search?: string }): Promise -getTemplateDetail(id: string): Promise -generate(req: ScriptGenerateRequest): Promise -getGenerations(): Promise +// scripts.ts +export const scriptsApi = { + getCategories(): Promise { ... }, + getTemplates(params?: { category_slug?: string; search?: string; tags?: string }): Promise { ... }, + getTemplateDetail(id: string): Promise { ... }, + generate(req: ScriptGenerateRequest): Promise { ... }, + getGenerations(): Promise { ... }, +} +``` + +Re-exported from `frontend/src/api/index.ts` as: + +```typescript +export { scriptsApi } from './scripts' ``` All methods use the existing `apiClient` (base URL `/api/v1`, auth interceptor handles token refresh). -Exported from `frontend/src/api/index.ts` as `scriptsApi`. +> `getGenerations()` and the `tags` param on `getTemplates` are Phase 3 scaffolding — included because the backend endpoints already exist and adding them later would require touching the same file. Neither is called by the Phase 2 UI. Mark both with a `// Phase 3` comment in the implementation. --- ## Component Tree -``` +```text ScriptLibraryPage pages/ScriptLibraryPage.tsx ├── ScriptFilterBar components/scripts/ScriptFilterBar.tsx ├── ScriptTemplateList components/scripts/ScriptTemplateList.tsx @@ -165,75 +238,116 @@ ScriptLibraryPage pages/ScriptLibraryPage.tsx ### Component responsibilities **`ScriptLibraryPage`** + - Bootstraps store on mount: calls `loadCategories()` then `loadTemplates()` -- Renders two-column layout (template list left, generator panel right) -- Renders `ScriptFilterBar` above the two columns -- No business logic — pure layout + bootstrap +- Owns `inputValue` state (search input text) and `onClearSearch` callback — lifted here so `ScriptFilterBar` and `ScriptTemplateList` can coordinate clear-search without direct coupling +- Renders `ScriptFilterBar` (passing `inputValue`, `setInputValue`, `onClearSearch`) above the two columns +- Renders two-column layout: `ScriptTemplateList` (left, passing `inputValue` + `onClearSearch`) and `ScriptGeneratorPanel` (right) +- Minimal logic — only the search state lift, everything else in the store or child components **`ScriptFilterBar`** -- Category tabs (pill style, `bg-primary/10` + cyan accent on active) -- Search input (debounced 300ms) — searches names, descriptions, and tags (backend handles it) -- Reads `categories`, `activeCategoryId`, `searchQuery` from store -- Calls `setCategory` and `setSearch` + +- Renders an "All" tab first (calls `setCategory(null)`, active when `activeCategoryId === null`), followed by one tab per category +- Category tabs: pill style, `bg-primary/10` + left 3px cyan accent bar on active tab +- Search: local `inputValue` state controls the `` value (NOT `searchQuery` from store — avoids input lag during debounce). `useEffect` watches `inputValue`, schedules `setTimeout(() => setSearch(inputValue), 300)`, clears timeout on cleanup +- Reads `categories`, `activeCategoryId` from store; does NOT use `searchQuery` from store to control the input +- Exposes a `onClearSearch` callback (or reads a `clearSearch` prop) — see `ScriptTemplateList` below for why. Simplest implementation: `ScriptLibraryPage` passes a `clearSearch` callback to both components; `ScriptFilterBar` exposes it as a function ref or the page wires `setInputValue` via a `useRef` **`ScriptTemplateList`** + - Scrollable list of `TemplateCard` components - Reads `templates`, `isLoadingTemplates`, `selectedTemplate` from store -- Shows skeleton loaders while loading -- Shows empty state when no templates match +- Accepts `inputValue: string` and `onClearSearch: () => void` props from `ScriptLibraryPage` +- Shows 3 skeleton placeholder cards while `isLoadingTemplates` is true +- Shows "No templates found" empty state when `templates.length === 0` and `!isLoadingTemplates` and `inputValue === ''` +- Shows "No templates match your search" + "Clear search" button when `templates.length === 0` and `!isLoadingTemplates` and `inputValue !== ''`. "Clear search" calls `onClearSearch()` which resets both `inputValue` in `ScriptFilterBar` and `searchQuery` in the store. Using `inputValue` (not `store.searchQuery`) avoids the 300ms debounce lag in empty-state detection **`TemplateCard`** -- Displays: name, description (truncated to 2 lines), complexity badge, usage count, tags -- Active state: `bg-primary/10` background + 3px left cyan accent bar (matches existing nav active pattern) + +- Displays: name, `complexity` badge, `usage_count`, description (2-line clamp via `line-clamp-2`), `tags` +- Shows a `requires_elevation` warning icon (Lucide `ShieldAlert`, amber-400) if true +- Active state when `template.id === selectedTemplate?.id`: `bg-primary/10` background + 3px left cyan gradient accent bar - Calls `selectTemplate(template.id)` on click -- Complexity badge colors: simple → emerald-400, moderate → amber-400, complex → rose-500 +- Complexity badge colors: beginner → emerald-400, intermediate → amber-400, advanced → rose-500 +- Slug is already URL/filesystem-safe by backend convention — no sanitization needed **`ScriptGeneratorPanel`** -- Shows placeholder ("Select a template to get started") when `selectedTemplate` is null -- When template selected: renders template name/description header, `ScriptParameterForm`, `ScriptPreview`, and action bar -- Action bar: Generate button (`bg-gradient-brand`) + Download `.ps1` button -- Download triggers `new Blob([generatedScript], { type: 'text/plain' })` → anchor click -- Shows `generateError` inline below Generate button -- Viewers see Generate button disabled with tooltip "Engineer access required" (checked via `usePermissions()`) + +- Shows placeholder (Terminal icon + "Select a template to get started") when `selectedTemplate` is null +- Shows spinner overlay when `isLoadingDetail` is true (template list remains visible behind it) +- When template selected and `!isLoadingDetail`: renders template name/description header, `ScriptParameterForm`, `ScriptPreview`, action bar +- Action bar (visible when template selected): + - Generate button (`bg-gradient-brand`) — calls `generate()`; shows spinner + disabled while `isGenerating` + - Download `.ps1` button — disabled when `generatedScript` is null; on click triggers `new Blob([generatedScript], { type: 'text/plain' })` → programmatic anchor click with `download="${selectedTemplate.slug}.ps1"` +- `generateError` shown as rose-500 text inline below action bar +- `generationWarnings` shown as amber-400 callout above the preview when `generationWarnings.length > 0` +- Permission check: call `usePermissions()` directly in this component; derive `canGenerate = !isViewer`. Pass `canGenerate` as a prop down to `ScriptParameterForm`. Generate and Download buttons disabled with tooltip "Engineer access required" when `!canGenerate` **`ScriptParameterForm`** -- Iterates `selectedTemplate.parameters_schema.parameters` sorted by `display_order` -- Renders a `ScriptParameterField` per parameter -- Client-side required validation on Generate (marks `formErrors` in store) -- Disabled entirely for viewers + +- Accepts `canGenerate: boolean` prop from `ScriptGeneratorPanel` +- Iterates `selectedTemplate.parameters_schema.parameters` sorted by `order` +- Groups by `group` field: renders a `font-label uppercase text-muted-foreground` section label before each group boundary (parameters with `group: null` rendered ungrouped at the top) +- Renders a `ScriptParameterField` per parameter, passing `canGenerate` down +- Does NOT own the Generate button and does NOT call `generate()` or `validate()` directly — validation is triggered from `ScriptGeneratorPanel` via the store's `generate()` action (which calls `validate()` internally) **`ScriptParameterField`** -- Renders input by `field_type`: text → ``, password → ``, select → ``, number → `` -- Shows `help_text` as a small muted line below the field -- Shows `formErrors[variable_name]` as an inline error -- Password fields show a show/hide toggle -- Calls `setParamValue(variable_name, value)` on change + +- Accepts `param: ScriptParameter`, `value: string`, `error: string | undefined`, `disabled: boolean` +- Renders input by `type`. Pass `error` prop to shared components (they render their own error message — do NOT add a separate error `

` below): + - `text` → `` + - `password` → `` with Lucide `Eye`/`EyeOff` toggle + - `textarea` → `