Relocate applications, geenforce, knowledgebase, and machines - each owns only its own views dir, so a clean move to plugins/<name>/frontend/ (views/ + routes.js, core imports rewritten to @/). geenforce's entryForm.js helper + its vitest spec move with it (ManifestEditor imports it as a sibling). Machinery fixes this batch surfaced: - routes.gen.js codegen uses namespace imports (import * as p_x). A route file without a `toplevel` export is undefined on the namespace instead of a strict- ESM missing-binding build error. - vitest gains a `pretest` stage so plugin-frontend specs (now under plugins/<name>/frontend/) run from their staged copy in src/.plugins-staged/. Verified live: GE-Enforce (the most complex, uses the entryForm sibling helper) renders fully from its staged frontend. Build + 58 vitest + naming green.
184 lines
9.1 KiB
JavaScript
184 lines
9.1 KiB
JavaScript
// Pure, framework-free helpers for the GE-Enforce manifest editor.
|
|
//
|
|
// ManifestEditor.vue imports these directly (the component no longer keeps its
|
|
// own copies), so the unit tests in entryForm.spec.js exercise the shipped
|
|
// code path. Change the editor logic HERE.
|
|
//
|
|
// Everything here is a plain function of its inputs. No Vue, no reactivity,
|
|
// no network. That is the whole point - deterministic logic we can pin down.
|
|
|
|
export const ENTRY_TYPES = ['MSI', 'EXE', 'CMD', 'BAT', 'PS1', 'INF', 'File', 'Registry']
|
|
export const REG_TYPES = ['String', 'DWord', 'QWord', 'MultiString', 'ExpandString', 'Binary']
|
|
export const DETECTION_METHODS = ['Registry', 'File', 'FileVersion', 'Hash', 'MarkerFile',
|
|
'ValueMatches', 'pnputil', 'Always']
|
|
export const INUSE_BEHAVIORS = ['Defer', 'CloseAndReopen', 'ForceClose', 'ScheduleForReboot']
|
|
|
|
// Plain-language description of each detection method, shown under the Detection
|
|
// method dropdown so a first-time site admin understands what "present" means
|
|
// for the method they picked. Key '' is the no-detection case.
|
|
export const DETECTION_METHOD_HINTS = {
|
|
'': 'No detection rule: the action runs every cycle.',
|
|
Registry: 'Already correct if the registry value at Detection path/name exists (and equals Detection value when one is set).',
|
|
File: 'Already correct if the file at Detection path exists.',
|
|
FileVersion: 'Already correct if the file at Detection path is at Detection value or newer. This target feeds the Compliance panel.',
|
|
Hash: 'Already correct if the file at Detection path matches the SHA256 in Detection value. Re-copies when the file changed.',
|
|
MarkerFile: 'Already correct if the marker file at Detection path exists. Installs once, then the marker suppresses reruns.',
|
|
ValueMatches: 'Already correct if the registry value at Detection path/name equals Detection value exactly.',
|
|
pnputil: 'Already correct if a driver matching Detection pattern is staged in the Windows driver store. For INF entries.',
|
|
Always: 'Never counts as present, so the action runs every cycle. Same effect as no detection rule.',
|
|
}
|
|
|
|
// Description for the currently selected detection method, or '' if unknown.
|
|
export function detectionMethodHint(method) {
|
|
return DETECTION_METHOD_HINTS[method || ''] || ''
|
|
}
|
|
|
|
// One blank entry form, matching the shape ManifestEditor seeds for a new entry.
|
|
export function blankEntry() {
|
|
return { Type: 'MSI', DetectionMethod: '', RegType: 'String',
|
|
inuseBehavior: '', inuseProcesses: [], appid: null,
|
|
PreEnrollment: false, KillAfterDetection: false, PCTypesStrict: false }
|
|
}
|
|
|
|
// Split a comma list into trimmed non-empty parts.
|
|
export function splitList(value) {
|
|
return (value || '').split(',').map(item => item.trim()).filter(Boolean)
|
|
}
|
|
|
|
// Turn an edit-form object into the API body sent to create/update an entry.
|
|
//
|
|
// Rules that matter (and that the specs pin):
|
|
// - appid is always present (null unlinks). It is shopdb metadata; the backend
|
|
// keeps it OFF the manifest JSON, so it is NOT one of the manifest scalars.
|
|
// - empty/undefined/null scalars are dropped.
|
|
// - Registry DWord/QWord values become real numbers; other reg types stay strings.
|
|
// - comma lists become arrays, and are dropped when empty.
|
|
// - preinstall flags only appear when truthy.
|
|
// - InUseCheck carries per-process Name, optional ExePath, optional numeric timeout.
|
|
export function buildEntryPayload(form) {
|
|
// appid is shopdb metadata, always sent (null unlinks); the backend keeps it
|
|
// off the manifest JSON.
|
|
const out = { Name: form.Name, Type: form.Type, appid: form.appid ?? null }
|
|
const scalars = ['Installer', 'InstallArgs', 'Script', 'Args', 'Source',
|
|
'Destination', 'RegPath', 'RegName', 'RegType', 'DetectionPath',
|
|
'DetectionName', 'DetectionValue', 'DetectionPattern', '_CmmVersion',
|
|
'LogFile', 'ApplyMode', 'UpdateWindow', '_comment']
|
|
for (const key of scalars) {
|
|
if (form[key] !== undefined && form[key] !== '' && form[key] !== null) out[key] = form[key]
|
|
}
|
|
if (form.DetectionMethod) out.DetectionMethod = form.DetectionMethod
|
|
if (form.WaitTimeoutSec) out.WaitTimeoutSec = form.WaitTimeoutSec
|
|
if (form.Type === 'Registry' && form.RegValue !== undefined && form.RegValue !== '') {
|
|
out.RegValue = ['DWord', 'QWord'].includes(form.RegType) ? Number(form.RegValue) : form.RegValue
|
|
}
|
|
const pctypes = splitList(form.PCTypes)
|
|
if (pctypes.length) out.PCTypes = pctypes
|
|
const hostnames = splitList(form.TargetHostnames)
|
|
if (hostnames.length) out.TargetHostnames = hostnames
|
|
const machinenumbers = splitList(form.TargetMachineNumbers)
|
|
if (machinenumbers.length) out.TargetMachineNumbers = machinenumbers
|
|
for (const flag of ['PreEnrollment', 'KillAfterDetection', 'PCTypesStrict']) {
|
|
if (form[flag]) out[flag] = true
|
|
}
|
|
if (form.inuseBehavior) {
|
|
out.InUseCheck = {
|
|
Behavior: form.inuseBehavior,
|
|
Processes: (form.inuseProcesses || []).filter(process => process.name).map(process => {
|
|
const processData = { Name: process.name }
|
|
if (process.exepath) processData.ExePath = process.exepath
|
|
if (process.timeout !== null && process.timeout !== '' && process.timeout !== undefined) {
|
|
processData.GracefulCloseTimeoutSec = Number(process.timeout)
|
|
}
|
|
return processData
|
|
}),
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Which entry types the type dropdown offers, given the scope phase. Preinstall
|
|
// supports MSI/EXE only, but keeps a current out-of-set value visible.
|
|
export function availableEntryTypes(isPreinstall, currentType) {
|
|
if (!isPreinstall) return ENTRY_TYPES
|
|
const allowed = ['MSI', 'EXE']
|
|
return currentType && !allowed.includes(currentType) ? [...allowed, currentType] : allowed
|
|
}
|
|
|
|
// Which detection methods the dropdown offers. Preinstall supports Registry/File
|
|
// only, but keeps a current out-of-set value visible.
|
|
export function availableDetectionMethods(isPreinstall, currentMethod) {
|
|
if (!isPreinstall) return DETECTION_METHODS
|
|
const allowed = ['Registry', 'File']
|
|
return currentMethod && !allowed.includes(currentMethod) ? [...allowed, currentMethod] : allowed
|
|
}
|
|
|
|
// Which targeting gates a scope actually surfaces by default (before "Show all").
|
|
export function targetingGates(scope) {
|
|
if (!scope) {
|
|
return { pctypes: true, cmmversion: false, machinenumbers: false, hostnames: false }
|
|
}
|
|
const name = (scope.scopename || '').toLowerCase()
|
|
const entries = scope.entries || []
|
|
const fleetwide = scope.iscommon || scope.phase === 'preinstall' || name === 'common'
|
|
return {
|
|
pctypes: fleetwide || entries.some(e => (e.PCTypes || []).length),
|
|
cmmversion: /cmm/.test(name) || entries.some(e => e._CmmVersion),
|
|
// Data-driven (a loose name match would wrongly flag 'nocollections').
|
|
machinenumbers: entries.some(e => (e.TargetMachineNumbers || []).length),
|
|
hostnames: entries.some(e => (e.TargetHostnames || []).length),
|
|
}
|
|
}
|
|
|
|
// The contextual hint under the Targeting header.
|
|
export function targetingHint(scope) {
|
|
if (!scope) return ''
|
|
if (scope.iscommon || scope.scopename === 'common') {
|
|
return 'Fleet-wide manifest: use PC types to target which types get this entry.'
|
|
}
|
|
if (scope.phase === 'preinstall') {
|
|
return 'Preinstall manifest: use PC types to target; preinstall flags apply here.'
|
|
}
|
|
return `This manifest already runs only on ${scope.scopename} PCs. `
|
|
+ 'Targeting below narrows within that (version, bay, or subtype).'
|
|
}
|
|
|
|
// One-line summary of what a scope installs (installer entries only).
|
|
export function scopeSummary(scope) {
|
|
if (!scope || !scope.entries || !scope.entries.length) return ''
|
|
const installers = scope.entries
|
|
.filter(e => ['MSI', 'EXE', 'CMD', 'BAT'].includes(e.Type))
|
|
.map(e => e.Name)
|
|
const shown = installers.slice(0, 6).join(', ')
|
|
const more = installers.length > 6 ? `, +${installers.length - 6} more` : ''
|
|
const apps = installers.length ? `Installs ${shown}${more}. ` : ''
|
|
return `${apps}${scope.entries.length} entries; runs after common.`
|
|
}
|
|
|
|
// Verb table for describeEntry.
|
|
export const ACTION_VERB = {
|
|
MSI: 'Installs', EXE: 'Installs', CMD: 'Runs', BAT: 'Runs',
|
|
PS1: 'Runs a script for', INF: 'Installs a driver for',
|
|
File: 'Copies a file for', Registry: 'Sets a registry value for',
|
|
}
|
|
|
|
// Plain-English one-liner: what an entry does, when it self-heals, who it hits.
|
|
export function describeEntry(entry) {
|
|
let text = `${ACTION_VERB[entry.Type] || 'Applies'} ${entry.Name}`
|
|
const method = entry.DetectionMethod
|
|
if (!method || method === 'Always') text += '; runs every cycle'
|
|
else if (method === 'FileVersion') text += `; reinstalls unless version is ${entry.DetectionValue || 'set'}`
|
|
else if (method === 'Hash') text += '; re-copies if the file changed'
|
|
else if (method === 'MarkerFile') text += '; installs once'
|
|
else text += '; reinstalls if not detected'
|
|
if (entry._CmmVersion) text += `; CMM ${entry._CmmVersion} bays only`
|
|
else if (entry.TargetMachineNumbers && entry.TargetMachineNumbers.length) {
|
|
text += `; ${entry.TargetMachineNumbers.length} specific bay(s)`
|
|
} else if (entry.TargetHostnames && entry.TargetHostnames.length) {
|
|
text += '; specific hostname(s)'
|
|
} else if (entry.PCTypes && entry.PCTypes.length) {
|
|
text += `; ${entry.PCTypes.length} PC type(s)`
|
|
}
|
|
if (entry.appname) text += `; tracked: ${entry.appname}`
|
|
return text
|
|
}
|