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>
301 lines
8.8 KiB
TypeScript
301 lines
8.8 KiB
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 {
|
|
const name = varName.replace(/^\$/, '')
|
|
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 } {
|
|
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` }
|
|
}
|
|
}
|
|
|
|
if (SENSITIVE_PATTERNS.test(varName)) {
|
|
return { type: 'password', sensitive: true, reason: 'Variable name suggests sensitive data — marked as sensitive' }
|
|
}
|
|
|
|
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' }
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
if ((trimmed.startsWith("'") && trimmed.endsWith("'")) ||
|
|
(trimmed.startsWith('"') && trimmed.endsWith('"'))) {
|
|
return trimmed.slice(1, -1)
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
/**
|
|
* Find the script-level param() block (not inside any function).
|
|
*/
|
|
function findScriptLevelParamBlock(script: string): { start: number; end: number } | null {
|
|
const lines = script.split('\n')
|
|
let functionBraceDepth = 0
|
|
let paramStart = -1
|
|
let parenDepth = 0
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const trimmed = lines[i].trim()
|
|
|
|
// Track function blocks with brace depth to handle nested braces
|
|
if (/^function\s+/i.test(trimmed)) {
|
|
// Count braces on this line and subsequent lines
|
|
for (const ch of lines[i]) {
|
|
if (ch === '{') functionBraceDepth++
|
|
if (ch === '}') functionBraceDepth--
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Track braces while inside a function
|
|
if (functionBraceDepth > 0) {
|
|
for (const ch of lines[i]) {
|
|
if (ch === '{') functionBraceDepth++
|
|
if (ch === '}') functionBraceDepth--
|
|
}
|
|
continue
|
|
}
|
|
|
|
if (functionBraceDepth === 0 && /^param\s*\(/i.test(trimmed) && paramStart === -1) {
|
|
paramStart = i
|
|
let foundOpen = false
|
|
for (let j = i; j < lines.length; j++) {
|
|
for (const ch of lines[j]) {
|
|
if (ch === '(') { parenDepth++; foundOpen = true }
|
|
if (ch === ')') parenDepth--
|
|
if (foundOpen && parenDepth === 0) {
|
|
return { start: paramStart, end: j }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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 candidates: ParameterCandidate[] = []
|
|
|
|
// Scan each line in the param block for $VarName patterns.
|
|
// Lines with [Parameter(...)], [ValidateSet(...)], etc. don't contain $VarName
|
|
// so they are naturally skipped. The type annotation [string], [int], etc.
|
|
// appears on the same line as $VarName.
|
|
const varLineRegex = /(?:\[(\w+)\])?\s*\$(\w+)(?:\s*=\s*(.+?))?(?:\s*,?\s*$)/
|
|
|
|
for (let i = block.start; i <= block.end; i++) {
|
|
const trimmed = lines[i].trim()
|
|
|
|
// Skip attribute lines like [Parameter(...)], [ValidateSet(...)], etc.
|
|
if (/^\[(?:Parameter|ValidateSet|ValidateRange|ValidatePattern|ValidateScript|ValidateLength|ValidateCount|Alias|AllowNull|AllowEmptyString|AllowEmptyCollection)\s*\(/i.test(trimmed)) {
|
|
continue
|
|
}
|
|
|
|
const match = trimmed.match(varLineRegex)
|
|
if (!match) continue
|
|
|
|
const typeAnnotation = match[1] || null
|
|
const varName = match[2]
|
|
const rawDefault = match[3]?.trim() ?? null
|
|
|
|
// Skip if type looks like an attribute we didn't catch above
|
|
if (typeAnnotation && /^Parameter$/i.test(typeAnnotation)) continue
|
|
|
|
const key = toSnakeCase(varName)
|
|
const { type, sensitive, reason } = inferType(typeAnnotation, rawDefault, varName)
|
|
const defaultValue = parseDefault(rawDefault, type)
|
|
|
|
candidates.push({
|
|
variableName: `$${varName}`,
|
|
suggestedKey: key,
|
|
suggestedLabel: toLabel(key),
|
|
suggestedType: type,
|
|
sensitive,
|
|
defaultValue,
|
|
source: 'param_block',
|
|
lineNumber: i + 1,
|
|
matchedLine: trimmed,
|
|
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>()
|
|
|
|
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()
|
|
|
|
if (SKIP_VARIABLES.has(fullVar)) continue
|
|
if (existingVarNames.has(fullVar)) continue
|
|
if (seenVars.has(fullVar)) continue
|
|
|
|
if (!/^['"].*['"]$/.test(rawValue) &&
|
|
!/^-?\d+(\.\d+)?$/.test(rawValue) &&
|
|
!/^\$(true|false)$/i.test(rawValue)) {
|
|
continue
|
|
}
|
|
|
|
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.
|
|
*/
|
|
export function detectParameterCandidates(script: string): ParameterCandidate[] {
|
|
if (!script.trim()) return []
|
|
|
|
const paramBlock = findScriptLevelParamBlock(script)
|
|
const paramCandidates = paramBlock
|
|
? extractParamBlockCandidates(script, paramBlock)
|
|
: []
|
|
|
|
const paramVarNames = new Set(paramCandidates.map(c => c.variableName))
|
|
const assignmentCandidates = extractAssignmentCandidates(script, paramVarNames)
|
|
|
|
return [...paramCandidates, ...assignmentCandidates]
|
|
}
|