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>
921 lines
28 KiB
Markdown
921 lines
28 KiB
Markdown
# 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 & Finish</>
|
|
) : (
|
|
<><ChevronRight size={13} /> Accept & 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
|
|
```
|