Files
resolutionflow/docs/superpowers/specs/2026-03-13-script-generator-phase2-design.md
chihlasm d4dbf44781 feat: Script Generator Phase 1+2 — backend, engine, API, frontend, template editor, parameter detector
Complete Script Generator feature including:

Backend:
- ScriptCategory, ScriptTemplate, ScriptGeneration models
- ScriptTemplateEngine with substitution, filters, sanitization
- CRUD + share API endpoints with permission checks
- Integration tests for permissions and sharing
- Migration 057 with AD User Management seed templates

Frontend — Script Library:
- Browse templates with category tabs and search
- Configure pane with parameter form and script generation
- Script preview with live substitution and copy/download
- scriptGeneratorStore Zustand store

Frontend — Template Editor:
- Full CRUD form with metadata, script body (Monaco Editor), parameters
- ParameterSchemaBuilder with visual builder + JSON toggle
- ScriptManagePage with routing and nav link

Frontend — Parameter Detector:
- Client-side PowerShell parameter detection engine
- Detects script-level param() blocks and variable assignments
- Type inference from PS type annotations and value patterns
- ParameterDetectorStepper one-by-one review UI with accept/skip

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 20:18:59 -04:00

24 KiB

Script Generator Phase 2 — Frontend Design

Date: 2026-03-13 Status: Approved Phase: 2 of Script Generator feature Builds on: Phase 1 backend (feat/script-generator PR #105)


Goal

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. 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 — 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 ({{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.


Zustand Store — scriptGeneratorStore

File: frontend/src/store/scriptGeneratorStore.ts

State shape

interface ScriptGeneratorState {
  // Template browsing
  categories: ScriptCategoryResponse[];
  templates: ScriptTemplateListItem[];
  selectedTemplate: ScriptTemplateDetail | null;
  searchQuery: string;
  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<string, string>;   // keyed by ScriptParameter.key; booleans stored as 'true'/'false'
  formErrors: Record<string, string>;    // keyed by ScriptParameter.key

  // Output
  generatedScript: string | null;
  generationId: string | null;
  generationWarnings: string[];
  isGenerating: boolean;
  generateError: string | null;

  // Actions
  loadCategories: () => Promise<void>;
  loadTemplates: () => Promise<void>;
  selectTemplate: (id: string) => Promise<void>;
  setCategory: (id: string | null) => void;
  setSearch: (query: string) => void;
  setParamValue: (key: string, value: string) => void;
  validate: () => boolean;
  generate: (sessionId?: string) => Promise<void>;
  clearOutput: () => void;
  reset: () => void;
}

Behaviour notes

  • setCategory updates activeCategoryId, then calls loadTemplates()
  • setSearch updates searchQuery, then calls loadTemplates(). The component (not the store) debounces its call to setSearchsetSearch 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. Prerequisite: loadCategories() must complete before loadTemplates() or setCategory() can resolve slugs — the page bootstrap calls them in this order
  • 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 as ScriptParametersSchema).parameters (cast required — backend types parameters_schema as dict; see ScriptTemplateDetail type comment), checks required && !paramValues[key] for each, writes errors to formErrors via set(), returns false if any required param is missing, true otherwise. Client-side validation of ScriptParameterValidation fields (pattern, min_length, max_length, min_value, max_value) is NOT implemented in Phase 2 — only required-field presence is checked. The backend validates these constraints on POST /scripts/generate and returns errors in detail. Phase 2 does not render per-field errors from the backend — all backend validation errors surface as a single generateError below the action bar
  • 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

Types

Added to frontend/src/types/index.ts:

export interface ScriptCategoryResponse {
  id: string;
  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;
  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 {
  key: string;
  label: string;
  type: 'text' | 'password' | 'select' | 'boolean' | 'multi_text' | 'number' | 'textarea';
  required: boolean;
  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 {
  parameters: ScriptParameter[];
}

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;
  default_values: Record<string, unknown>;   // template-level metadata from backend; not used by Phase 2 UI
  validation_rules: Record<string, unknown>; // template-level metadata from backend; not used by Phase 2 UI
  version: number;
  created_at: string;
  updated_at: string;
}

export interface ScriptGenerateRequest {
  template_id: string;
  parameters: Record<string, unknown>;
  session_id?: string;
}

export interface ScriptGenerateResponse {
  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<string, unknown>; // sensitive values already redacted by backend
  created_at: string;
}

API Client

File: frontend/src/api/scripts.ts — use a named export object (matching the copilotApi/assistantChatApi pattern in api/index.ts):

// scripts.ts
export const scriptsApi = {
  getCategories(): Promise<ScriptCategoryResponse[]> { ... },
  getTemplates(params?: { category_slug?: string; search?: string; tags?: string }): Promise<ScriptTemplateListItem[]> { ... },
  getTemplateDetail(id: string): Promise<ScriptTemplateDetail> { ... },
  generate(req: ScriptGenerateRequest): Promise<ScriptGenerateResponse> { ... },
  getGenerations(): Promise<ScriptGenerationRecord[]> { ... },
}

Re-exported from frontend/src/api/index.ts as:

export { scriptsApi } from './scripts'

All methods use the existing apiClient (base URL /api/v1, auth interceptor handles token refresh).

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

ScriptLibraryPage                    pages/ScriptLibraryPage.tsx
├── ScriptFilterBar                  components/scripts/ScriptFilterBar.tsx
├── ScriptTemplateList               components/scripts/ScriptTemplateList.tsx
│   └── TemplateCard                 components/scripts/TemplateCard.tsx
└── ScriptGeneratorPanel             components/scripts/ScriptGeneratorPanel.tsx
    ├── ScriptParameterForm          components/scripts/ScriptParameterForm.tsx
    │   └── ScriptParameterField     components/scripts/ScriptParameterField.tsx
    └── ScriptPreview                components/scripts/ScriptPreview.tsx
        └── PowerShellHighlighter    components/scripts/PowerShellHighlighter.tsx

Component responsibilities

ScriptLibraryPage

  • Bootstraps store on mount: calls loadCategories() then loadTemplates()
  • 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

  • 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
  • Receives inputValue: string and setInputValue: (v: string) => void as props from ScriptLibraryPage (state is lifted — ScriptFilterBar does NOT own local search state)
  • <Input> is controlled by inputValue prop. useEffect inside ScriptFilterBar watches inputValue, schedules setTimeout(() => setSearch(inputValue), 300), clears timeout on cleanup — debounce lives here but the value lives in the page
  • Reads categories, activeCategoryId from store

ScriptTemplateList

  • Scrollable list of TemplateCard components
  • Reads templates, isLoadingTemplates, selectedTemplate from store
  • 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() — a callback prop from ScriptLibraryPage defined as () => { setInputValue(''); store.setSearch(''); }. Using inputValue (not store.searchQuery) avoids the 300ms debounce lag in empty-state detection

TemplateCard

  • 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: beginner → emerald-400, intermediate → amber-400, advanced → rose-500
  • Slug is already URL/filesystem-safe by backend convention — no sanitization needed

ScriptGeneratorPanel

  • Shows placeholder (Terminal icon + "Select a template to get started") when selectedTemplate is null
  • Shows a full-panel centered spinner when isLoadingDetail is true, replacing the panel content (not an overlay — no prior template content shown while loading). The template list column remains fully visible and interactive
  • When template selected and !isLoadingDetail: renders template name/description header, ScriptParameterForm, ScriptPreview, action bar (in that order, top to bottom)
  • Visual order within the panel (top to bottom): warnings callout → ScriptPreview → action bar → error text
  • generationWarnings shown as amber-400 callout above the preview when generationWarnings.length > 0
  • Action bar (below preview):
    • Generate button (bg-gradient-brand) — calls generate() with no arguments (Phase 3 will pass sessionId); disabled when isGenerating OR !canGenerate; shows spinner while isGenerating
    • Download .ps1 button — disabled when generatedScript is null OR !canGenerate; 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
  • Permission check: call usePermissions() directly in this component; derive canGenerate = isEngineer. Pass canGenerate as a prop down to ScriptParameterForm. Generate and Download buttons disabled with tooltip "Engineer access required" when !canGenerate

ScriptParameterForm

  • Accepts canGenerate: boolean prop from ScriptGeneratorPanel
  • Iterates selectedTemplate.parameters_schema.parameters sorted by order. Access via cast: (selectedTemplate.parameters_schema as ScriptParametersSchema).parametersparameters_schema arrives as dict at runtime (backend types it as dict; helper comment in ScriptTemplateDetail type definition)
  • 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 disabled={!canGenerate} (converts canGenerate boolean to the disabled prop expected by ScriptParameterField)
  • 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

  • Accepts param: ScriptParameter, value: string, error: string | undefined, disabled: boolean
  • Renders input by type. Two error rendering approaches depending on type:
    • Shared error rendering — pass error prop to the shared component; it renders its own error message (do NOT add a separate <p> below):
      • text<Input error={error} />
      • password<Input type="password" error={error} /> with Lucide Eye/EyeOff toggle
      • textarea<Textarea error={error} />
      • number<Input type="number" error={error} />
      • multi_text<Input placeholder="Comma-separated values" error={error} />; stored as single string, backend splits on comma
    • Manual error rendering — no shared component; render error explicitly as <p className="mt-1.5 text-xs text-red-400">{error}</p> below the input:
      • select → native <select className="w-full rounded-[10px] border border-border bg-card text-foreground px-3 py-2 text-sm focus:outline-none focus:border-[rgba(6,182,212,0.3)]" disabled={disabled}>; options from parameter.options
      • boolean<input type="checkbox" className="rounded border-border" checked={value === 'true'} onChange={e => setParamValue(key, e.target.checked ? 'true' : 'false')} disabled={disabled} />
  • Shows help_text as <p className="text-xs text-muted-foreground mt-1"> below the input (before the error)
  • Calls setParamValue(key, value) on every change event; boolean uses e.target.checked ? 'true' : 'false'

ScriptPreview

  • Two modes determined by generatedScript in store:
    • Draft mode (generatedScript === null): client-side {{key}} substitution in selectedTemplate.script_body. Sensitive params (parameter.sensitive === true) rendered as exactly four asterisks **** regardless of paramValues value. Unfilled non-sensitive params left as {{key}} for PowerShellHighlighter to color amber
    • Generated mode (generatedScript !== null): shows generatedScript
  • The component computes displayScript as a local variable (the substituted preview string in draft mode, or generatedScript in generated mode). Both the render and the copy handler reference this same local variable — no separate store field needed
  • Copy icon (Lucide Copy, 14px) in top-right corner of code block, visible in both modes:
    • On click: calls navigator.clipboard.writeText(displayScript). On success: shows "Copied!" for 2s then resets. On failure: silently fails — no error displayed, icon does not change state
  • Passes displayScript to PowerShellHighlighter

PowerShellHighlighter

  • Pure component: ({ script: string }) => JSX.Element

  • Single-pass tokenizer using a combined alternation regex (not sequential replace). The regex alternation matches tokens in priority order; each match is replaced with a <span> and the remaining string continues from after the match. This prevents a variable inside a string literal from being re-colored by the variable rule:

    Priority order in alternation:
    1. Comments: /#[^\r\n]*/
    2. String literals: /"[^"]*"|'[^']*'/
    3. Unfilled placeholders: /\{\{[^}]+\}\}/
    4. Variables: /\$\w+/
    5. Cmdlets: /[A-Z][a-z]+-[A-Z][a-zA-Z]+/
    6. Parameters: /-[A-Za-z]+/
    7. Keywords: /\b(if|else|elseif|foreach|for|while|function|return|try|catch|finally|param|switch)\b/
    

    Note: variables (priority 4) consume tokens like $foreach before keywords (priority 7) can match — this is intentional. Do not reorder the alternation without reviewing this interaction.

    Token colors:

    • Comments → text-[#8b949e]
    • String literals → text-[#a5d6ff]
    • Unfilled placeholders → text-amber-400 underline decoration-dashed
    • Variables → text-[#79c0ff]
    • Cmdlets → text-[#22d3ee]
    • Parameters → text-[#d2a8ff]
    • Keywords → text-[#ff7b72]
    • Unmatched text → unstyled
  • Renders as <pre className="font-label text-sm bg-card rounded-xl p-4 overflow-x-auto"><code>


Routing & Navigation

  • Route: /scripts added to frontend/src/router.tsx inside the ProtectedRoute/AppLayout children
  • Sidebar nav entry: "Scripts" with a Terminal icon (Lucide), added to AppLayout.tsx in the main nav group
  • No sub-routes needed for Phase 2

Permissions

Action Minimum role
View Script Library page Any authenticated user
Browse templates, see draft preview Any authenticated user
Fill form, generate, copy, download Engineer, owner, or super_admin

usePermissions() is called once in ScriptGeneratorPanel. Derive canGenerate = isEngineer (usePermissions().isEngineer returns true for engineer, owner, and super_admin via the role hierarchy check — equivalent to !isViewer given the four-role system, but isEngineer is more semantically explicit). Pass canGenerate as a prop down to ScriptParameterForm. Leaf components (ScriptParameterField) receive disabled as a prop — they do not call usePermissions() directly.

Viewer-blocking is frontend-only in Phase 2 (backend role guard deferred — known limitation, documented in Architecture section).

Tooltip implementation: Use the HTML title attribute on the wrapper <span> around disabled buttons — consistent with the existing codebase pattern (e.g. TopBar.tsx, NavItem.tsx, CheckoutButton.tsx). Example: <span title="Engineer access required"><button disabled ...>Generate</button></span>.


Search Behaviour

  • Search query sent as ?search= to GET /scripts/templates; matches name, description, slug (not tags)
  • 300ms debounce in ScriptFilterBar: local inputValue state, useEffect + setTimeout/clearTimeout
  • setSearch is called once after the debounce delay; it immediately calls loadTemplates()
  • Category filter and search compose: loadTemplates() sends both category_slug and search simultaneously
  • inputValue is owned by ScriptLibraryPage (lifted state). ScriptLibraryPage passes it as a prop to ScriptFilterBar (which controls the <Input>) and to ScriptTemplateList (which uses it to choose the correct empty-state variant). onClearSearch — also defined in ScriptLibraryPage as () => { setInputValue(''); store.setSearch(''); } — is passed to ScriptTemplateList for the "Clear search" button

Empty & Loading States

Scenario Treatment
Templates loading 3 skeleton TemplateCard placeholders (isLoadingTemplates)
No templates in category Empty state icon + "No templates found"
No search results "No templates match your search" + "Clear search" button
Detail fetch in progress Spinner in ScriptGeneratorPanel (isLoadingDetail); template list stays visible
No template selected Right panel: Terminal icon + "Select a template to get started"
Generating Generate button spinner + disabled (isGenerating)
Generate error Rose-500 inline text below action bar
Warnings after generate Amber-400 callout above preview, one line per warning

Out of Scope (Phase 2)

  • Session-embedded script generation (Script Output Node) — Phase 3
  • Tag filter UI — deferred (backend capability exists via ?tags=)
  • Backend engineer role guard on generate endpoint — deferred (known gap)
  • Template creation/editing UI — admin-only, deferred
  • Generation history page — deferred
  • Admin template management — deferred
  • Script execution / RMM integration — long-term roadmap