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>
This commit was merged in pull request #105.
This commit is contained in:
chihlasm
2026-03-14 20:18:59 -04:00
committed by GitHub
parent 83b13fcd26
commit d4dbf44781
50 changed files with 11916 additions and 11 deletions

View File

@@ -0,0 +1,157 @@
# Script Template Editor — Design Document
> **Date:** 2026-03-13
> **Status:** Approved
> **Depends on:** Script Generator Phase 1 (backend) + Phase 2 (Script Library frontend)
---
## Goal
Build a Script Template Editor page where engineers can create and manage their own PowerShell script templates, and owners/admins can promote personal templates to team-wide visibility via a "Share with team" toggle.
---
## Page Structure
**Route:** `/scripts/manage`
**Two modes:**
- **List mode** — all templates the user can see/edit. Filterable by category, searchable by name. Each row: name, category, complexity badge, usage count, scope badge ("Personal" / "Team"), action buttons (Edit, Delete).
- **Editor mode** — full-page form for creating or editing a template. No modal — the template has too many fields. "Back to templates" link with unsaved-changes warning if dirty.
**Navigation entry points:**
- "Manage Templates" link on the Script Library page header (visible to engineers+)
- Direct URL `/scripts/manage`
---
## Permissions
### Who sees what in the list
| Role | Visible templates |
|------|------------------|
| Engineer | Own templates (`created_by = user.id`) + team templates (`team_id = user.team_id`) |
| Owner / Admin | All templates in their account scope |
| Super admin | All templates across all accounts |
### Who can do what
| Action | Engineer | Owner/Admin | Super Admin |
|--------|----------|-------------|-------------|
| Create template | Yes (personal scope) | Yes (personal or team) | Yes (any scope) |
| Edit own template | Yes | Yes | Yes |
| Edit others' templates | No | Yes (within account) | Yes (all) |
| Delete own template | Yes (soft delete) | Yes | Yes |
| Delete others' templates | No | Yes (within account) | Yes (all) |
| "Share with team" toggle | No | Yes | Yes |
---
## Backend Changes
### Permission refactor
Replace `_require_team_admin()` with `_check_template_permission(user, template?)`:
- **Create:** engineers+ can create (personal scope)
- **Edit/Delete:** engineers can modify own templates (`created_by == user.id`); owners/admins can modify any template in their account; super admins can modify any
- Existing `POST /scripts/templates` sets `created_by = user.id` and `team_id = null` for engineers (personal scope)
### New endpoint
`PATCH /scripts/templates/{id}/share` — owner/admin/super_admin only
- Body: `{ "shared": boolean }`
- `shared=true` → sets `team_id` to the user's `team_id`
- `shared=false` → clears `team_id` to `null` (reverts to personal scope for original author)
- Returns updated `ScriptTemplateDetail`
### Query filter
Update `GET /scripts/templates` to support `managed=true` query param:
- Returns templates the user can edit (own templates + team templates for owners/admins)
- Used by the manage page list view
---
## Template Editor Form
Single scrollable page with sections separated by dividers. Fixed bottom action bar.
### Section 1: Metadata
- **Name** — text input, required
- **Description** — textarea
- **Use Case** — textarea ("when would you use this?")
- **Category** — select dropdown from existing categories
- **Complexity** — select: beginner / intermediate / advanced
- **Tags** — multi-text input (comma-separated or chip-style)
- **Estimated Runtime** — text input (e.g., "30 seconds")
- **Requires Elevation** — checkbox
- **Required Modules** — multi-text input
- **Share with team** — toggle switch, visible only to owners/admins/super_admins. Help text: "When enabled, all team members can browse and use this template."
### Section 2: Script Body
- Large textarea with `PowerShellHighlighter` for syntax coloring
- Monospace font (`font-label` / JetBrains Mono)
- `{{parameter_key}}` placeholders highlighted in amber so authors can see where parameters slot in
- Simple textarea with highlighting overlay (no Monaco/CodeMirror dependency)
### Section 3: Parameters Schema
Two modes with a toggle at the top of the section:
**Visual mode (default):**
- List of parameter cards, each expandable/collapsible
- "Add Parameter" button at bottom
- Each card: key, label, type (select from 7 types), required toggle, placeholder, group, order, help_text, default value, sensitive toggle
- For `select` type: options sub-list (value + label pairs)
- For types with validation: min/max/pattern fields
- Drag-to-reorder or up/down arrows for parameter ordering
**JSON mode:**
- Raw JSON editor showing the `parameters_schema` object
- Edits sync back to visual mode on switch
- Parse errors shown inline
### Section 4: Fixed Action Bar
- **Save** — primary button, creates or updates template
- **Cancel** — back to list with dirty-state warning
- **Delete** — danger button (right-aligned), only in edit mode, confirmation modal, soft delete
---
## Team Sharing Behavior
- **Default for engineer-created templates:** `team_id = null` (personal, only visible to creator)
- **Shared:** `team_id` set to account's team — template appears in Script Library for all team members
- **Unsharing:** reverts to personal scope for original author; author retains edit access via `created_by`
---
## Frontend Components (Expected)
| Component | Responsibility |
|-----------|---------------|
| `ScriptManagePage.tsx` | Page shell, list/editor mode toggle |
| `ScriptTemplateListView.tsx` | Template list with filters, search, action buttons |
| `ScriptTemplateEditor.tsx` | Full editor form — metadata, script body, parameters |
| `ParameterSchemaBuilder.tsx` | Visual parameter builder with add/remove/reorder |
| `ParameterCard.tsx` | Single parameter editor (expandable card) |
| `ParameterJsonEditor.tsx` | Raw JSON mode for parameters schema |
| `ScriptBodyEditor.tsx` | Textarea with PowerShell highlighting overlay |
| `ShareToggle.tsx` | Team sharing toggle (owner/admin only) |
---
## Design System Compliance
- Dark glassmorphism theme, `.glass-card-static` containers
- Primary actions: `bg-gradient-brand`
- Section labels: `font-label text-[0.625rem] uppercase tracking-[0.1em]`
- Form inputs: `border-border bg-card text-foreground` with cyan focus ring
- Complexity badges: emerald (beginner), amber (intermediate), rose (advanced)
- Scope badges: "Personal" (muted border) / "Team" (cyan/primary tint)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,116 @@
# Parameter Detector — Design Doc
> **Date:** 2026-03-14
> **Status:** Approved
> **Scope:** Frontend only (no backend changes)
## Overview
A client-side tool in the Script Template Editor that scans a PowerShell script body, detects hardcoded values that should be parameterized, and walks the user through converting them one-by-one via a stepper UI.
## Detection Engine
Pure TypeScript utility (`lib/scriptParameterDetector.ts`) that takes a script body string and returns `ParameterCandidate[]`.
### Detection targets (in order)
1. **Script-level `param()` blocks** — the `param(...)` block at the top of the script, before any `function` keyword. Extracts name, type annotation, and default value. Skips `param()` blocks inside `function` declarations.
2. **Variable assignments**`$VarName = 'value'`, `$VarName = "value"`, `$VarName = 123`, `$VarName = $true/$false`. Skips variables already found in the param block and PowerShell internals like `$ErrorActionPreference`.
### Type inference
| Detection | Suggested Type | Sensitive |
|-----------|---------------|-----------|
| `[string]` or plain string value | `text` | no |
| `[switch]` or `$true`/`$false` | `boolean` | no |
| `[int]`, `[int32]`, `[int64]` or numeric value | `number` | no |
| `[SecureString]` or name contains password/secret/key/credential | `password` | yes |
| No type info, string value | `text` | no |
### Candidate shape
```typescript
interface ParameterCandidate {
variableName: string // "$OUPath"
suggestedKey: string // "ou_path"
suggestedLabel: string // "OU Path"
suggestedType: ScriptParameter['type']
sensitive: boolean
defaultValue: string | boolean | number | null
source: 'param_block' | 'assignment'
lineNumber: number
matchedLine: string
inferenceReason: string // "Detected [switch] type declaration"
}
```
## Stepper UI
`ParameterDetectorStepper` component renders inline below the ScriptBodyEditor in the Script Body section.
### Trigger
- "Detect Parameters" button (secondary style, Wand2/Scan icon) below the script body textarea
- Hidden if script body is empty; disabled while stepper is active
- If no candidates found: brief "No parameter candidates detected" message
### Stepper layout
Shows one candidate at a time with:
- Progress indicator ("Candidate 2 of 5" + dots)
- Matched line displayed in monospace
- Editable fields: Key, Label, Type (with info icon showing inferenceReason), Default value
- Checkboxes: Required, Sensitive
- Actions: Skip, Accept & Next (last item: Accept & Finish / Skip & Finish)
### On accept
1. Script body: replace the hardcoded value with `{{key}}`
2. Parameters schema: append a new `ScriptParameter` with suggested values + original value as `default`
### Edge cases
- Script body edited during detection → stepper dismisses
- Key conflicts with existing parameter → warning + suggested alternative
- Re-running after partial conversion → skips already-converted `{{key}}` placeholders
## Integration
### Component tree
```
ScriptTemplateEditor
├── Metadata section
├── Script Body section
│ ├── ScriptBodyEditor
│ ├── "Detect Parameters" button ← NEW
│ └── ParameterDetectorStepper ← NEW (conditional)
├── Parameters section
│ └── ParameterSchemaBuilder
└── Fixed Action Bar
```
### Data flow
- Detection runs client-side via `detectParameterCandidates(script_body)`
- Candidates stored in local React state on ScriptTemplateEditor
- Accept updates `form.script_body` and `form.parameters_schema` via existing `updateField()`
- `isDirty` flag set automatically — user can cancel without saving to undo everything
- No new backend endpoints needed
## File changes
**New files:**
- `frontend/src/lib/scriptParameterDetector.ts`
- `frontend/src/components/script-editor/ParameterDetectorStepper.tsx`
**Modified files:**
- `frontend/src/components/script-editor/ScriptTemplateEditor.tsx`
- `frontend/src/types/scripts.ts`
## Out of scope
- No backend changes
- No AI-powered detection (future enhancement)
- No auto-detection on paste
- No individual undo for accepted parameters

View File

@@ -0,0 +1,920 @@
# Parameter Detector Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add a client-side PowerShell parameter detection tool to the Script Template Editor that scans script bodies for hardcoded values and walks users through converting them to template parameters via a stepper UI.
**Architecture:** Pure frontend feature. A detection engine (`lib/scriptParameterDetector.ts`) parses PowerShell script bodies using regex to find script-level `param()` block entries and variable assignments. A stepper component (`ParameterDetectorStepper`) presents candidates one-by-one for review. Accepted candidates update both `form.script_body` (value → `{{key}}`) and `form.parameters_schema` (new `ScriptParameter` appended).
**Tech Stack:** TypeScript, React, Lucide icons, Tailwind CSS, existing ScriptParameter types
**Design doc:** `docs/plans/2026-03-14-parameter-detector-design.md`
---
### Task 1: Add ParameterCandidate type
**Files:**
- Modify: `frontend/src/types/scripts.ts:124` (append after `ScriptTemplateUpdateRequest`)
**Step 1: Add the interface**
Add to the end of `frontend/src/types/scripts.ts`:
```typescript
export interface ParameterCandidate {
variableName: string
suggestedKey: string
suggestedLabel: string
suggestedType: ScriptParameter['type']
sensitive: boolean
defaultValue: string | boolean | number | null
source: 'param_block' | 'assignment'
lineNumber: number
matchedLine: string
inferenceReason: string
}
```
**Step 2: Export from types index**
Verify `ParameterCandidate` is exported from `frontend/src/types/index.ts`. If scripts types are re-exported with `export * from './scripts'`, it's automatic. Otherwise add the export.
**Step 3: Run build to verify**
Run: `cd frontend && npm run build`
Expected: SUCCESS
**Step 4: Commit**
```bash
git add frontend/src/types/scripts.ts
git commit -m "feat: add ParameterCandidate type for script parameter detection"
```
---
### Task 2: Build detection engine
**Files:**
- Create: `frontend/src/lib/scriptParameterDetector.ts`
**Step 1: Create the detection utility**
Create `frontend/src/lib/scriptParameterDetector.ts` with the following:
```typescript
import type { ScriptParameter, ParameterCandidate } from '@/types'
/**
* PowerShell variable names to skip — these are PS internals, not user inputs.
*/
const SKIP_VARIABLES = new Set([
'$ErrorActionPreference',
'$WarningPreference',
'$VerbosePreference',
'$DebugPreference',
'$InformationPreference',
'$ConfirmPreference',
'$ProgressPreference',
'$PSDefaultParameterValues',
'$PSModuleAutoLoadingPreference',
'$OFS',
'$FormatEnumerationLimit',
'$MaximumHistoryCount',
'$_',
'$PSItem',
'$args',
'$input',
'$this',
'$null',
'$true',
'$false',
])
/**
* Sensitive variable name patterns — if the variable name contains any of these,
* suggest password type and mark sensitive.
*/
const SENSITIVE_PATTERNS = /password|secret|key|credential|token|apikey|api_key/i
/**
* Convert a PowerShell variable name to a snake_case key.
* "$OUPath" → "ou_path", "$ServerName" → "server_name"
*/
function toSnakeCase(varName: string): string {
// Strip leading $
const name = varName.replace(/^\$/, '')
// Insert underscore before uppercase letters, then lowercase everything
return name
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
.toLowerCase()
}
/**
* Convert a snake_case key to a human-readable label.
* "ou_path" → "OU Path", "server_name" → "Server Name"
*/
function toLabel(key: string): string {
return key
.split('_')
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ')
}
/**
* Infer the ScriptParameter type from a PowerShell type annotation and/or value.
*/
function inferType(
typeAnnotation: string | null,
value: string | null,
varName: string
): { type: ScriptParameter['type']; sensitive: boolean; reason: string } {
// Check type annotation first
if (typeAnnotation) {
const t = typeAnnotation.toLowerCase()
if (t === 'switch') {
return { type: 'boolean', sensitive: false, reason: 'Detected [switch] type declaration' }
}
if (t === 'securestring') {
return { type: 'password', sensitive: true, reason: 'Detected [SecureString] type — marked as sensitive' }
}
if (t === 'int' || t === 'int32' || t === 'int64' || t === 'double' || t === 'float' || t === 'decimal') {
return { type: 'number', sensitive: false, reason: `Detected [${typeAnnotation}] type declaration` }
}
if (t === 'bool' || t === 'boolean') {
return { type: 'boolean', sensitive: false, reason: `Detected [${typeAnnotation}] type declaration` }
}
// [string] or other → fall through to value/name checks
}
// Check variable name for sensitive patterns
if (SENSITIVE_PATTERNS.test(varName)) {
return { type: 'password', sensitive: true, reason: `Variable name suggests sensitive data — marked as sensitive` }
}
// Check value patterns
if (value !== null) {
const trimmed = value.trim()
if (trimmed === '$true' || trimmed === '$false') {
return { type: 'boolean', sensitive: false, reason: 'Detected boolean value ($true/$false)' }
}
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
return { type: 'number', sensitive: false, reason: 'Detected numeric value' }
}
}
// Default
const reason = typeAnnotation
? `Detected [${typeAnnotation}] type declaration`
: 'Defaulting to text (no type annotation detected)'
return { type: 'text', sensitive: false, reason }
}
/**
* Parse the default value into the correct JS type.
*/
function parseDefault(value: string | null, type: ScriptParameter['type']): string | boolean | number | null {
if (value === null) return null
const trimmed = value.trim()
if (type === 'boolean') {
if (trimmed === '$true') return true
if (trimmed === '$false') return false
return null
}
if (type === 'number') {
const n = Number(trimmed)
return isNaN(n) ? null : n
}
// Strip surrounding quotes for string values
if ((trimmed.startsWith("'") && trimmed.endsWith("'")) ||
(trimmed.startsWith('"') && trimmed.endsWith('"'))) {
return trimmed.slice(1, -1)
}
return trimmed
}
/**
* Find the end index of a script-level param() block.
* Returns -1 if no script-level param block is found.
* Skips param() blocks inside function declarations.
*/
function findScriptLevelParamBlock(script: string): { start: number; end: number } | null {
const lines = script.split('\n')
let inFunction = false
let paramStart = -1
let parenDepth = 0
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim()
// Track function blocks — skip param() inside functions
if (/^function\s+/i.test(trimmed)) {
inFunction = true
continue
}
// Find script-level param keyword
if (!inFunction && /^param\s*\(/i.test(trimmed) && paramStart === -1) {
paramStart = i
// Count parens to find the closing )
for (let j = i; j < lines.length; j++) {
for (const ch of lines[j]) {
if (ch === '(') parenDepth++
if (ch === ')') parenDepth--
if (parenDepth === 0 && paramStart !== -1) {
return { start: paramStart, end: j }
}
}
}
}
// Reset function tracking at closing brace (simplified)
if (inFunction && trimmed === '}') {
inFunction = false
}
}
return null
}
/**
* Extract parameter candidates from a script-level param() block.
*/
function extractParamBlockCandidates(
script: string,
block: { start: number; end: number }
): ParameterCandidate[] {
const lines = script.split('\n')
const blockText = lines.slice(block.start, block.end + 1).join('\n')
const candidates: ParameterCandidate[] = []
// Match patterns like: [string]$VarName = "default" or $VarName or [switch]$VarName
// Supports [Parameter(Mandatory=$true)] attributes on preceding lines
const paramRegex = /(?:\[(\w+)\])?\s*\$(\w+)(?:\s*=\s*(.+?))?(?:\s*,\s*$|\s*$|\s*\))/gm
let match: RegExpExecArray | null
while ((match = paramRegex.exec(blockText)) !== null) {
const typeAnnotation = match[1] || null
const varName = match[2]
const rawDefault = match[3]?.trim() ?? null
// Skip Parameter() attributes — they look like [Parameter(...)]
if (typeAnnotation && /^Parameter$/i.test(typeAnnotation)) continue
const key = toSnakeCase(varName)
const { type, sensitive, reason } = inferType(typeAnnotation, rawDefault, varName)
const defaultValue = parseDefault(rawDefault, type)
// Find the actual line number in the original script
const lineIndex = lines.findIndex((line, idx) =>
idx >= block.start && idx <= block.end && line.includes(`$${varName}`)
)
candidates.push({
variableName: `$${varName}`,
suggestedKey: key,
suggestedLabel: toLabel(key),
suggestedType: type,
sensitive,
defaultValue,
source: 'param_block',
lineNumber: lineIndex !== -1 ? lineIndex + 1 : block.start + 1,
matchedLine: lineIndex !== -1 ? lines[lineIndex].trim() : `$${varName}`,
inferenceReason: reason,
})
}
return candidates
}
/**
* Extract parameter candidates from variable assignments ($Var = 'value').
*/
function extractAssignmentCandidates(
script: string,
existingVarNames: Set<string>
): ParameterCandidate[] {
const lines = script.split('\n')
const candidates: ParameterCandidate[] = []
const seenVars = new Set<string>()
// Match: $VarName = 'value' | "value" | 123 | $true | $false
const assignRegex = /^\s*(\$\w+)\s*=\s*(.+)$/
for (let i = 0; i < lines.length; i++) {
const match = lines[i].match(assignRegex)
if (!match) continue
const fullVar = match[1]
const rawValue = match[2].trim()
// Skip PS internals
if (SKIP_VARIABLES.has(fullVar)) continue
// Skip if already found in param block
if (existingVarNames.has(fullVar)) continue
// Skip if already seen (take first assignment only)
if (seenVars.has(fullVar)) continue
// Skip if value is a complex expression (function call, pipeline, etc.)
// Only match: quoted strings, numbers, $true/$false
if (!/^['"].*['"]$/.test(rawValue) &&
!/^-?\d+(\.\d+)?$/.test(rawValue) &&
!/^\$(true|false)$/i.test(rawValue)) {
continue
}
// Skip if the value already contains a {{placeholder}}
if (/\{\{.*?\}\}/.test(rawValue)) continue
seenVars.add(fullVar)
const varName = fullVar.replace(/^\$/, '')
const key = toSnakeCase(varName)
const { type, sensitive, reason } = inferType(null, rawValue, varName)
const defaultValue = parseDefault(rawValue, type)
candidates.push({
variableName: fullVar,
suggestedKey: key,
suggestedLabel: toLabel(key),
suggestedType: type,
sensitive,
defaultValue,
source: 'assignment',
lineNumber: i + 1,
matchedLine: lines[i].trim(),
inferenceReason: reason,
})
}
return candidates
}
/**
* Detect parameter candidates in a PowerShell script body.
* Returns candidates from script-level param() block first, then variable assignments.
*/
export function detectParameterCandidates(script: string): ParameterCandidate[] {
if (!script.trim()) return []
// 1. Find and extract script-level param block
const paramBlock = findScriptLevelParamBlock(script)
const paramCandidates = paramBlock
? extractParamBlockCandidates(script, paramBlock)
: []
// Track param block var names to avoid duplicates in assignment scan
const paramVarNames = new Set(paramCandidates.map(c => c.variableName))
// 2. Extract variable assignments
const assignmentCandidates = extractAssignmentCandidates(script, paramVarNames)
return [...paramCandidates, ...assignmentCandidates]
}
```
**Step 2: Run build to verify**
Run: `cd frontend && npm run build`
Expected: SUCCESS
**Step 3: Commit**
```bash
git add frontend/src/lib/scriptParameterDetector.ts
git commit -m "feat: add PowerShell parameter detection engine"
```
---
### Task 3: Build ParameterDetectorStepper component
**Files:**
- Create: `frontend/src/components/script-editor/ParameterDetectorStepper.tsx`
**Step 1: Create the stepper component**
Create `frontend/src/components/script-editor/ParameterDetectorStepper.tsx`:
```tsx
import { useState } from 'react'
import { ChevronRight, SkipForward, Info, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Input } from '@/components/ui/Input'
import type { ParameterCandidate, ScriptParameter } from '@/types'
const PARAM_TYPES: { value: ScriptParameter['type']; label: string }[] = [
{ value: 'text', label: 'Text' },
{ value: 'password', label: 'Password' },
{ value: 'textarea', label: 'Textarea' },
{ value: 'number', label: 'Number' },
{ value: 'boolean', label: 'Boolean' },
{ value: 'select', label: 'Select' },
{ value: 'multi_text', label: 'Multi-text' },
]
interface Props {
candidates: ParameterCandidate[]
existingKeys: string[]
onAccept: (candidate: ParameterCandidate, overrides: {
key: string
label: string
type: ScriptParameter['type']
sensitive: boolean
required: boolean
defaultValue: string | boolean | number | null
}) => void
onSkip: (candidate: ParameterCandidate) => void
onFinish: (acceptedCount: number, totalCount: number) => void
}
export function ParameterDetectorStepper({
candidates,
existingKeys,
onAccept,
onSkip,
onFinish,
}: Props) {
const [currentIndex, setCurrentIndex] = useState(0)
const [acceptedCount, setAcceptedCount] = useState(0)
const [showInferenceInfo, setShowInferenceInfo] = useState(false)
// Editable overrides for the current candidate
const current = candidates[currentIndex]
const [key, setKey] = useState(current.suggestedKey)
const [label, setLabel] = useState(current.suggestedLabel)
const [type, setType] = useState<ScriptParameter['type']>(current.suggestedType)
const [sensitive, setSensitive] = useState(current.sensitive)
const [required, setRequired] = useState(true)
const [defaultValue, setDefaultValue] = useState(
current.defaultValue !== null ? String(current.defaultValue) : ''
)
const isLast = currentIndex === candidates.length - 1
const keyConflict = existingKeys.includes(key) ||
candidates.slice(0, currentIndex).some((_, i) => {
// This is a simplification — actual conflict check happens against
// the running list of accepted keys which is managed by the parent
return false
})
const resetFieldsForIndex = (index: number) => {
const c = candidates[index]
setKey(c.suggestedKey)
setLabel(c.suggestedLabel)
setType(c.suggestedType)
setSensitive(c.sensitive)
setRequired(true)
setDefaultValue(c.defaultValue !== null ? String(c.defaultValue) : '')
setShowInferenceInfo(false)
}
const handleAccept = () => {
const parsedDefault = type === 'boolean'
? defaultValue === 'true'
: type === 'number'
? (defaultValue ? Number(defaultValue) : null)
: (defaultValue || null)
onAccept(current, {
key,
label,
type,
sensitive,
required,
defaultValue: parsedDefault,
})
const newAccepted = acceptedCount + 1
setAcceptedCount(newAccepted)
if (isLast) {
onFinish(newAccepted, candidates.length)
} else {
const nextIndex = currentIndex + 1
setCurrentIndex(nextIndex)
resetFieldsForIndex(nextIndex)
}
}
const handleSkip = () => {
onSkip(current)
if (isLast) {
onFinish(acceptedCount, candidates.length)
} else {
const nextIndex = currentIndex + 1
setCurrentIndex(nextIndex)
resetFieldsForIndex(nextIndex)
}
}
return (
<div className="border border-primary/20 rounded-xl bg-primary/[0.03] p-4 space-y-3">
{/* Progress */}
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-foreground">
Candidate {currentIndex + 1} of {candidates.length}
</p>
<div className="flex items-center gap-1">
{candidates.map((_, i) => (
<div
key={i}
className={cn(
'h-1.5 w-1.5 rounded-full transition-colors',
i < currentIndex ? 'bg-primary' :
i === currentIndex ? 'bg-primary animate-pulse' :
'bg-border'
)}
/>
))}
</div>
</div>
{/* Matched line */}
<div className="rounded-lg bg-black/20 px-3 py-2">
<p className="font-label text-xs text-amber-400 break-all">
{current.matchedLine}
</p>
<p className="font-label text-[0.5rem] text-muted-foreground mt-1">
Line {current.lineNumber} · {current.source === 'param_block' ? 'param() block' : 'variable assignment'}
</p>
</div>
{/* Editable fields */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">Key</label>
<Input
value={key}
onChange={e => setKey(e.target.value.replace(/[^a-zA-Z0-9_]/g, ''))}
placeholder="param_key"
/>
{existingKeys.includes(key) && (
<p className="text-[0.625rem] text-amber-400 mt-0.5">Key already exists consider a different name</p>
)}
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">Label</label>
<Input
value={label}
onChange={e => setLabel(e.target.value)}
placeholder="Display Label"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 flex items-center gap-1.5">
Type
<button
type="button"
onClick={() => setShowInferenceInfo(!showInferenceInfo)}
className="text-muted-foreground hover:text-primary transition-colors"
title={current.inferenceReason}
>
<Info size={11} />
</button>
</label>
<select
value={type}
onChange={e => setType(e.target.value as ScriptParameter['type'])}
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)]"
>
{PARAM_TYPES.map(t => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
{showInferenceInfo && (
<p className="text-[0.625rem] text-primary/80 mt-1 italic">
{current.inferenceReason}
</p>
)}
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">Default value</label>
<Input
value={defaultValue}
onChange={e => setDefaultValue(e.target.value)}
placeholder="Original value preserved"
/>
</div>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={required}
onChange={e => setRequired(e.target.checked)}
className="rounded border-border"
/>
Required
</label>
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={sensitive}
onChange={e => setSensitive(e.target.checked)}
className="rounded border-border"
/>
Sensitive
</label>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-2 pt-1 border-t border-border">
<button
type="button"
onClick={handleSkip}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors px-3 py-1.5"
>
<SkipForward size={13} />
{isLast ? 'Skip & Finish' : 'Skip'}
</button>
<button
type="button"
onClick={handleAccept}
disabled={!key.trim() || !label.trim()}
className="flex items-center gap-1.5 bg-gradient-brand text-[#101114] font-semibold text-sm px-4 py-1.5 rounded-[10px] hover:opacity-90 active:scale-[0.97] transition-all shadow-lg shadow-primary/20 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLast ? (
<><Check size={13} /> Accept &amp; Finish</>
) : (
<><ChevronRight size={13} /> Accept &amp; Next</>
)}
</button>
</div>
</div>
)
}
```
**Step 2: Run build to verify**
Run: `cd frontend && npm run build`
Expected: SUCCESS
**Step 3: Commit**
```bash
git add frontend/src/components/script-editor/ParameterDetectorStepper.tsx
git commit -m "feat: add ParameterDetectorStepper component"
```
---
### Task 4: Wire detection into ScriptTemplateEditor
**Files:**
- Modify: `frontend/src/components/script-editor/ScriptTemplateEditor.tsx`
**Step 1: Add imports**
At the top of `ScriptTemplateEditor.tsx`, add:
```typescript
import { Scan } from 'lucide-react'
import { detectParameterCandidates } from '@/lib/scriptParameterDetector'
import { ParameterDetectorStepper } from './ParameterDetectorStepper'
import type { ParameterCandidate, ScriptParameter } from '@/types'
```
Update the existing `lucide-react` import to include `Scan` alongside the existing icons.
**Step 2: Add detection state**
Inside the `ScriptTemplateEditor` component, after the existing `useState` declarations (around line 59), add:
```typescript
const [detectedCandidates, setDetectedCandidates] = useState<ParameterCandidate[]>([])
const [showStepper, setShowStepper] = useState(false)
const [detectionSummary, setDetectionSummary] = useState<string | null>(null)
```
**Step 3: Add detection handler**
After `handleBack` (around line 188), add:
```typescript
const handleDetectParameters = () => {
const candidates = detectParameterCandidates(form.script_body)
if (candidates.length === 0) {
setDetectionSummary('No parameter candidates detected in the script body.')
setShowStepper(false)
setTimeout(() => setDetectionSummary(null), 4000)
return
}
setDetectedCandidates(candidates)
setDetectionSummary(null)
setShowStepper(true)
}
const handleAcceptCandidate = (
candidate: ParameterCandidate,
overrides: {
key: string
label: string
type: ScriptParameter['type']
sensitive: boolean
required: boolean
defaultValue: string | boolean | number | null
}
) => {
// 1. Replace the value in the script body with {{key}}
let updatedScript = form.script_body
if (candidate.source === 'param_block') {
// For param block: replace the default value portion
// e.g., $VarName = "default" → $VarName = "{{key}}"
const defaultMatch = candidate.matchedLine.match(/=\s*(.+?)(?:\s*,?\s*$)/)
if (defaultMatch) {
updatedScript = updatedScript.replace(
candidate.matchedLine,
candidate.matchedLine.replace(defaultMatch[1], `'{{${overrides.key}}}'`)
)
}
} else {
// For assignment: replace the right-hand side value
const assignMatch = candidate.matchedLine.match(/=\s*(.+)$/)
if (assignMatch) {
updatedScript = updatedScript.replace(
candidate.matchedLine,
candidate.matchedLine.replace(assignMatch[1], `'{{${overrides.key}}}'`)
)
}
}
// 2. Append new parameter to the schema
const existingParams = form.parameters_schema.parameters
const newParam: ScriptParameter = {
key: overrides.key,
label: overrides.label,
type: overrides.type,
required: overrides.required,
placeholder: null,
group: null,
order: existingParams.length + 1,
help_text: null,
options: null,
default: overrides.defaultValue,
validation: null,
sensitive: overrides.sensitive,
}
// Update both fields
setForm(f => ({
...f,
script_body: updatedScript,
parameters_schema: {
parameters: [...f.parameters_schema.parameters, newParam],
},
}))
setIsDirty(true)
}
const handleSkipCandidate = () => {
// Nothing to do — stepper advances internally
}
const handleDetectionFinish = (acceptedCount: number, totalCount: number) => {
setShowStepper(false)
setDetectedCandidates([])
setDetectionSummary(
acceptedCount === 0
? 'No parameters were added.'
: `Added ${acceptedCount} of ${totalCount} detected parameter${totalCount !== 1 ? 's' : ''}.`
)
setTimeout(() => setDetectionSummary(null), 5000)
}
```
**Step 4: Add UI elements to the Script Body section**
In the JSX, find the Script Body section (around line 334-348). After the `<ScriptBodyEditor>` and before `</section>`, add the detect button and stepper:
```tsx
<ScriptBodyEditor
value={form.script_body}
onChange={v => updateField('script_body', v)}
/>
{/* Detect Parameters button + stepper */}
{form.script_body.trim() && !showStepper && (
<button
type="button"
onClick={handleDetectParameters}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] hover:border-[rgba(255,255,255,0.12)] px-3 py-1.5 rounded-[10px] transition-all"
>
<Scan size={14} />
Detect Parameters
</button>
)}
{detectionSummary && (
<p className="text-xs text-muted-foreground italic">{detectionSummary}</p>
)}
{showStepper && detectedCandidates.length > 0 && (
<ParameterDetectorStepper
candidates={detectedCandidates}
existingKeys={form.parameters_schema.parameters.map(p => p.key)}
onAccept={handleAcceptCandidate}
onSkip={handleSkipCandidate}
onFinish={handleDetectionFinish}
/>
)}
```
**Step 5: Run build to verify**
Run: `cd frontend && npm run build`
Expected: SUCCESS
**Step 6: Commit**
```bash
git add frontend/src/components/script-editor/ScriptTemplateEditor.tsx
git commit -m "feat: wire parameter detection into ScriptTemplateEditor"
```
---
### Task 5: Manual testing checklist
**Step 1: Test with variable assignments**
1. Navigate to `/scripts/manage` → click New Template
2. Paste this script body:
```powershell
$ServerName = 'DC01'
$OUPath = 'OU=Users,DC=contoso,DC=com'
$DefaultPassword = 'Welcome123!'
$ForceChange = $true
$MaxRetries = 3
```
3. Click "Detect Parameters"
4. Verify 5 candidates appear in stepper
5. Verify type inference: ServerName=text, OUPath=text, DefaultPassword=password+sensitive, ForceChange=boolean, MaxRetries=number
6. Accept all — verify script body has `{{key}}` placeholders and Parameters section has 5 entries with defaults preserved
**Step 2: Test with param() block**
Paste:
```powershell
param(
[string]$ServerName = "DC01",
[switch]$WhatIf,
[SecureString]$AdminPassword,
[int]$Port = 443
)
$Connection = "https://$ServerName:$Port"
```
Expected: 4 candidates from param block (ServerName, WhatIf, AdminPassword, Port) + 0 from assignments (Connection is a complex expression, not a simple literal)
**Step 3: Test with function-level param (should be skipped)**
Paste:
```powershell
$GlobalPath = 'C:\Scripts'
function Load-Users {
param($filter = "*")
Get-ADUser -Filter $filter
}
```
Expected: 1 candidate only (GlobalPath). The function-level `param($filter)` should NOT appear.
**Step 4: Test edge cases**
- Empty script body → Detect Parameters button hidden
- Script with only `{{key}}` placeholders → "No parameter candidates detected"
- Script with PS internals like `$ErrorActionPreference = 'Stop'` → skipped
- Re-running detect after accepting some → already-converted values skipped
**Step 5: Commit any fixes**
```bash
git commit -m "fix: address issues found during parameter detector testing"
```
---
### Task 6: Final build verification and push
**Step 1: Run full build**
Run: `cd frontend && npm run build`
Expected: SUCCESS with no type errors
**Step 2: Push**
```bash
git push
```