Files
poshmanager/server/models/psadtModel.js
2026-06-25 12:05:53 -05:00

540 lines
21 KiB
JavaScript

import { db, id, json, now, visibleClause } from '../db.js';
import { intuneDeploymentSchema, psadtProfileSchema } from '../forms/schemas.js';
import { psadtCatalog } from '../data/psadtCatalog.js';
import { camelIntuneDeployment, camelPsadtProfile } from './mappers.js';
function normalizeProfile(body, existing = {}) {
const parsed = psadtProfileSchema.parse({ ...existing, ...body });
return {
...parsed,
appName: parsed.zeroConfig ? '' : parsed.appName,
requireAdmin: Boolean(parsed.requireAdmin),
zeroConfig: Boolean(parsed.zeroConfig),
suppressReboot: Boolean(parsed.suppressReboot),
groupId: parsed.visibility === 'group' ? parsed.groupId || null : null
};
}
function normalizeIntuneDeployment(body, existing = {}, userId = null) {
const parsed = intuneDeploymentSchema.parse({ ...existing, ...body });
const rendered = parsed.profileId ? renderPsadtProfile(parsed.profileId, userId) : null;
const defaultCommands = commandsForStyle(parsed.commandStyle, parsed.restartBehavior);
return {
...parsed,
profileId: parsed.profileId || null,
scriptId: parsed.scriptId || null,
applicationId: parsed.applicationId || null,
sourceFolder: parsed.sourceFolder || '',
intunewinFile: parsed.intunewinFile || '',
graphAppId: parsed.graphAppId || '',
graphConnectionId: parsed.graphConnectionId || null,
requirements: {
architecture: parsed.requirements?.architecture || 'x64',
minOs: parsed.requirements?.minOs || 'Windows 10 22H2',
diskSpaceMb: Number(parsed.requirements?.diskSpaceMb || 0),
runAs32Bit: Boolean(parsed.requirements?.runAs32Bit)
},
installCommand: parsed.installCommand || (parsed.commandStyle === 'v4' ? rendered?.intuneInstallCommand : '') || defaultCommands.install,
uninstallCommand: parsed.uninstallCommand || (parsed.commandStyle === 'v4' ? rendered?.intuneUninstallCommand : '') || defaultCommands.uninstall,
returnCodes: parsed.returnCodes.length ? parsed.returnCodes : [
{ code: 0, type: 'success', meaning: 'Install completed successfully.' },
{ code: 1602, type: 'retry', meaning: 'User deferred or cancelled; let Intune retry.' },
{ code: 1703, type: 'softReboot', meaning: 'Legacy soft reboot mapping used by some PSADT Intune examples.' },
{ code: 3010, type: 'softReboot', meaning: 'Install completed and a reboot is required.' },
{ code: 60008, type: 'failed', meaning: 'PSADT initialization failure.' }
],
assignments: parsed.assignments.length ? parsed.assignments : [
{ ring: 'Pilot', intent: 'available', groupName: '', notes: 'Validation ring before broad assignment.' }
],
relationships: parsed.relationships || [],
autoPromoteAfterHours: Number(parsed.autoPromoteAfterHours) || 0,
autoPromoteRing: parsed.autoPromoteRing || '',
autoPromoteIntent: parsed.autoPromoteIntent || 'required',
groupId: parsed.visibility === 'group' ? parsed.groupId || null : null
};
}
function commandsForStyle(commandStyle = 'v4', restartBehavior = 'return-code') {
const suppress = restartBehavior === 'suppress' ? ' -SuppressRebootPassThru' : '';
if (commandStyle === 'legacy-v3') {
return {
install: `Deploy-Application.exe -DeploymentType "Install" -DeployMode "Interactive"${suppress}`,
uninstall: `Deploy-Application.exe -DeploymentType "Uninstall" -DeployMode "Interactive"${suppress}`
};
}
return {
install: `Invoke-AppDeployToolkit.exe -DeploymentType Install${suppress}`,
uninstall: `Invoke-AppDeployToolkit.exe -DeploymentType Uninstall${suppress}`
};
}
export function getPsadtCatalog() {
return psadtCatalog();
}
export function listIntuneDeployments(userId) {
return db.prepare(`
SELECT d.*, p.name AS profile_name, s.name AS script_name, g.name AS group_name
FROM intune_deployments d
LEFT JOIN psadt_profiles p ON p.id = d.profile_id
LEFT JOIN scripts s ON s.id = d.script_id
LEFT JOIN groups g ON g.id = d.group_id
WHERE ${visibleClause('d')}
ORDER BY d.updated_at DESC
`).all(userId, userId).map(camelIntuneDeployment);
}
export function loadIntuneDeployment(deploymentId, userId) {
const row = db.prepare(`
SELECT d.*, p.name AS profile_name, s.name AS script_name, g.name AS group_name
FROM intune_deployments d
LEFT JOIN psadt_profiles p ON p.id = d.profile_id
LEFT JOIN scripts s ON s.id = d.script_id
LEFT JOIN groups g ON g.id = d.group_id
WHERE d.id = ? AND ${visibleClause('d')}
`).get(deploymentId, userId, userId);
return row ? camelIntuneDeployment(row) : null;
}
export function createIntuneDeployment(body, userId) {
const payload = normalizeIntuneDeployment(body, {}, userId);
const deploymentId = id('intune');
db.prepare(`
INSERT INTO intune_deployments (
id, name, profile_id, script_id, application_id, source_folder, intunewin_file, app_type,
command_style, install_behavior, restart_behavior, ui_mode, requirements,
install_command, uninstall_command, detection_type, detection_rule, return_codes, assignments, relationships, status, graph_app_id, graph_connection_id, notes,
auto_promote_after_hours, auto_promote_ring, auto_promote_intent,
visibility, owner_user_id, group_id, created_by, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
deploymentId,
payload.name,
payload.profileId,
payload.scriptId,
payload.applicationId,
payload.sourceFolder,
payload.intunewinFile,
payload.appType,
payload.commandStyle,
payload.installBehavior,
payload.restartBehavior,
payload.uiMode,
json(payload.requirements, {}),
payload.installCommand,
payload.uninstallCommand,
payload.detectionType,
payload.detectionRule,
json(payload.returnCodes),
json(payload.assignments),
json(payload.relationships),
payload.status,
payload.graphAppId,
payload.graphConnectionId,
payload.notes,
payload.autoPromoteAfterHours,
payload.autoPromoteRing,
payload.autoPromoteIntent,
payload.visibility,
userId,
payload.groupId,
userId,
now(),
now()
);
return loadIntuneDeployment(deploymentId, userId);
}
export function updateIntuneDeployment(deploymentId, body, userId) {
const existing = loadIntuneDeployment(deploymentId, userId);
if (!existing) return null;
const payload = normalizeIntuneDeployment(body, existing, userId);
db.prepare(`
UPDATE intune_deployments
SET name = ?, profile_id = ?, script_id = ?, application_id = ?, source_folder = ?, intunewin_file = ?,
app_type = ?, command_style = ?, install_behavior = ?, restart_behavior = ?,
ui_mode = ?, requirements = ?, install_command = ?, uninstall_command = ?,
detection_type = ?, detection_rule = ?, return_codes = ?, assignments = ?, relationships = ?,
status = ?, graph_app_id = ?, graph_connection_id = ?, notes = ?,
auto_promote_after_hours = ?, auto_promote_ring = ?, auto_promote_intent = ?,
visibility = ?, group_id = ?, updated_at = ?
WHERE id = ?
`).run(
payload.name,
payload.profileId,
payload.scriptId,
payload.applicationId,
payload.sourceFolder,
payload.intunewinFile,
payload.appType,
payload.commandStyle,
payload.installBehavior,
payload.restartBehavior,
payload.uiMode,
json(payload.requirements, {}),
payload.installCommand,
payload.uninstallCommand,
payload.detectionType,
payload.detectionRule,
json(payload.returnCodes),
json(payload.assignments),
json(payload.relationships),
payload.status,
payload.graphAppId,
payload.graphConnectionId,
payload.notes,
payload.autoPromoteAfterHours,
payload.autoPromoteRing,
payload.autoPromoteIntent,
payload.visibility,
payload.groupId,
now(),
deploymentId
);
return loadIntuneDeployment(deploymentId, userId);
}
export function deleteIntuneDeployment(deploymentId, userId) {
db.prepare(`DELETE FROM intune_deployments WHERE id = ? AND ${visibleClause()}`).run(deploymentId, userId, userId);
}
// Start the soak clock on first successful publish (auto-promote uses this).
export function markDeploymentPublished(deploymentId) {
db.prepare('UPDATE intune_deployments SET soak_started_at = ? WHERE id = ? AND (soak_started_at IS NULL OR soak_started_at = \'\')')
.run(now(), deploymentId);
}
// System-context helpers for the auto-promote worker (no visibility scope).
export function loadDeploymentRaw(deploymentId) {
const row = db.prepare('SELECT * FROM intune_deployments WHERE id = ?').get(deploymentId);
return row ? camelIntuneDeployment(row) : null;
}
export function listPromotionCandidates() {
return db.prepare(`
SELECT * FROM intune_deployments
WHERE auto_promote_after_hours > 0 AND soak_started_at IS NOT NULL AND soak_started_at <> ''
AND (promoted_at IS NULL OR promoted_at = '') AND auto_promote_ring IS NOT NULL AND auto_promote_ring <> ''
`).all().map(camelIntuneDeployment);
}
export function applyPromotion(deploymentId, assignments) {
db.prepare('UPDATE intune_deployments SET assignments = ?, promoted_at = ?, updated_at = ? WHERE id = ?')
.run(json(assignments), now(), now(), deploymentId);
return loadDeploymentRaw(deploymentId);
}
export function listPsadtProfiles(userId) {
return db.prepare(`
SELECT p.*, g.name AS group_name
FROM psadt_profiles p
LEFT JOIN groups g ON g.id = p.group_id
WHERE ${visibleClause('p')}
ORDER BY p.updated_at DESC
`).all(userId, userId).map(camelPsadtProfile);
}
export function createPsadtProfile(body, userId) {
const payload = normalizeProfile(body);
const profileId = id('psadt');
db.prepare(`
INSERT INTO psadt_profiles (
id, name, app_vendor, app_name, app_version, app_arch, app_lang, app_revision,
toolkit_version, template_version, deployment_type, deploy_mode, require_admin,
zero_config, suppress_reboot, close_processes, install_tasks, ui_plan, config_plan,
admx_plan, visibility, owner_user_id, group_id, created_by, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
profileId,
payload.name,
payload.appVendor,
payload.appName,
payload.appVersion,
payload.appArch,
payload.appLang,
payload.appRevision,
payload.toolkitVersion,
payload.templateVersion,
payload.deploymentType,
payload.deployMode,
payload.requireAdmin ? 1 : 0,
payload.zeroConfig ? 1 : 0,
payload.suppressReboot ? 1 : 0,
json(payload.closeProcesses),
json(payload.installTasks),
json(payload.uiPlan, {}),
json(payload.configPlan, {}),
json(payload.admxPlan, {}),
payload.visibility,
userId,
payload.groupId,
userId,
now(),
now()
);
return loadPsadtProfile(profileId, userId);
}
export function loadPsadtProfile(profileId, userId) {
const row = db.prepare(`
SELECT p.*, g.name AS group_name
FROM psadt_profiles p
LEFT JOIN groups g ON g.id = p.group_id
WHERE p.id = ? AND ${visibleClause('p')}
`).get(profileId, userId, userId);
return row ? camelPsadtProfile(row) : null;
}
export function updatePsadtProfile(profileId, body, userId) {
const existing = loadPsadtProfile(profileId, userId);
if (!existing) return null;
const payload = normalizeProfile(body, existing);
db.prepare(`
UPDATE psadt_profiles
SET name = ?, app_vendor = ?, app_name = ?, app_version = ?, app_arch = ?, app_lang = ?,
app_revision = ?, toolkit_version = ?, template_version = ?, deployment_type = ?,
deploy_mode = ?, require_admin = ?, zero_config = ?, suppress_reboot = ?,
close_processes = ?, install_tasks = ?, ui_plan = ?, config_plan = ?, admx_plan = ?,
visibility = ?, group_id = ?, updated_at = ?
WHERE id = ?
`).run(
payload.name,
payload.appVendor,
payload.appName,
payload.appVersion,
payload.appArch,
payload.appLang,
payload.appRevision,
payload.toolkitVersion,
payload.templateVersion,
payload.deploymentType,
payload.deployMode,
payload.requireAdmin ? 1 : 0,
payload.zeroConfig ? 1 : 0,
payload.suppressReboot ? 1 : 0,
json(payload.closeProcesses),
json(payload.installTasks),
json(payload.uiPlan, {}),
json(payload.configPlan, {}),
json(payload.admxPlan, {}),
payload.visibility,
payload.groupId,
now(),
profileId
);
return loadPsadtProfile(profileId, userId);
}
export function deletePsadtProfile(profileId, userId) {
db.prepare(`DELETE FROM psadt_profiles WHERE id = ? AND ${visibleClause()}`).run(profileId, userId, userId);
}
export function renderPsadtProfile(profileId, userId) {
const profile = loadPsadtProfile(profileId, userId);
if (!profile) return null;
const script = renderScript(profile);
const commandParts = [
'Invoke-AppDeployToolkit.exe',
`-DeploymentType ${profile.deploymentType}`,
profile.deployMode && profile.deployMode !== 'Auto' ? `-DeployMode ${profile.deployMode}` : '',
profile.suppressReboot ? '-SuppressRebootPassThru' : ''
].filter(Boolean);
const deployCommand = commandParts.join(' ');
const psCommand = [
'%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\PowerShell.exe -ExecutionPolicy Bypass -NoProfile -File Invoke-AppDeployToolkit.ps1',
`-DeploymentType ${profile.deploymentType}`,
profile.deployMode && profile.deployMode !== 'Auto' ? `-DeployMode ${profile.deployMode}` : ''
].filter(Boolean).join(' ');
return {
profile,
scriptName: `${profile.name.replace(/[^\w.-]+/g, '-') || 'Invoke-AppDeployToolkit'}.ps1`,
deployCommand,
powershellCommand: psCommand,
intuneInstallCommand: deployCommand,
intuneUninstallCommand: deployCommand.replace(`-DeploymentType ${profile.deploymentType}`, '-DeploymentType Uninstall'),
script
};
}
function renderScript(profile) {
const closeProcesses = profile.closeProcesses?.length
? `@(${profile.closeProcesses.map((item) => `@{ Name = '${ps(item.name)}'; Description = '${ps(item.description || item.name)}' }`).join(', ')})`
: '@()';
const installTasks = profile.installTasks?.length ? profile.installTasks : defaultInstallTasks(profile);
const phase = (name, lines) => [
`## MARK: ${name}`,
`New-Variable -Name ${name} -Value {`,
...lines.map((line) => line ? ` ${line}` : ''),
'}'
].join('\n');
const welcomeLines = [
'$saiwParams = @{',
' AllowDefer = $true',
' DeferTimes = 3',
' CheckDiskSpace = $true',
' PersistPrompt = $true',
'}',
'if ($adtSession.AppProcessesToClose.Count -gt 0)',
'{',
" $saiwParams.Add('CloseProcesses', $adtSession.AppProcessesToClose)",
'}',
'Show-ADTInstallationWelcome @saiwParams',
'Show-ADTInstallationProgress'
];
return [
'<#',
'.SYNOPSIS',
'This script invokes a PSAppDeployToolkit deployment generated by POSHManager.',
'',
'.DESCRIPTION',
`PSADT profile: ${profile.name}`,
'The scaffold follows the upstream v4 frontend template shape and imports PSAppDeployToolkit from the package when available.',
'#>',
'',
'[CmdletBinding()]',
'param',
'(',
" [Parameter(Mandatory = $false)]",
" [ValidateSet('Install', 'Uninstall', 'Repair')]",
' [System.String]$DeploymentType,',
'',
" [Parameter(Mandatory = $false)]",
" [ValidateSet('Auto', 'Interactive', 'NonInteractive', 'Silent')]",
' [System.String]$DeployMode,',
'',
' [Parameter(Mandatory = $false)]',
' [System.Management.Automation.SwitchParameter]$SuppressRebootPassThru',
')',
'',
'## MARK: Variables',
'$adtSession = @{',
` AppVendor = '${ps(profile.appVendor)}'`,
` AppName = '${ps(profile.appName)}'`,
` AppVersion = '${ps(profile.appVersion)}'`,
` AppArch = '${ps(profile.appArch)}'`,
` AppLang = '${ps(profile.appLang)}'`,
` AppRevision = '${ps(profile.appRevision)}'`,
' AppSuccessExitCodes = @(0)',
' AppRebootExitCodes = @(1641, 3010)',
` AppProcessesToClose = ${closeProcesses}`,
'',
" AppScriptVersion = '1.0.0'",
` AppScriptDate = '${new Date().toISOString().slice(0, 10)}'`,
" AppScriptAuthor = 'POSHManager'",
'',
' DeployAppScriptFriendlyName = $MyInvocation.MyCommand.Name',
' DeployAppScriptParameters = $PSBoundParameters',
" DeployAppScriptVersion = '4.2.0'",
` RequireAdmin = ${profile.requireAdmin ? '$true' : '$false'}`,
'}',
'',
phase('Pre-Install', profile.uiPlan?.welcome === false ? ['# Welcome prompt disabled by profile.'] : welcomeLines),
'',
phase('Install', installTasks.filter((task) => task.phase === 'Install').map(renderTask)),
'',
phase('Post-Install', ["Show-ADTInstallationPrompt -Message \"$($adtSession.DeploymentType) complete.\" -ButtonRightText 'OK' -NoWait"]),
'',
phase('Pre-Uninstall', [
'if ($adtSession.AppProcessesToClose.Count -gt 0)',
'{',
' Show-ADTInstallationWelcome -CloseProcesses $adtSession.AppProcessesToClose -CloseProcessesCountdown 60',
'}',
'Show-ADTInstallationProgress'
]),
'',
phase('Uninstall', installTasks.filter((task) => task.phase === 'Uninstall').map(renderTask)),
'',
phase('Post-Uninstall', []),
'',
phase('Pre-Repair', [
'if ($adtSession.AppProcessesToClose.Count -gt 0)',
'{',
' Show-ADTInstallationWelcome -CloseProcesses $adtSession.AppProcessesToClose -CloseProcessesCountdown 60',
'}',
'Show-ADTInstallationProgress'
]),
'',
phase('Repair', installTasks.filter((task) => task.phase === 'Repair').map(renderTask)),
'',
phase('Post-Repair', ["Show-ADTInstallationPrompt -Message \"$($adtSession.DeploymentType) complete.\" -ButtonRightText 'OK' -NoWait"]),
'',
'## MARK: Initialization',
'$ErrorActionPreference = [System.Management.Automation.ActionPreference]::Stop',
'$ProgressPreference = [System.Management.Automation.ActionPreference]::SilentlyContinue',
'Set-StrictMode -Version 1',
'try',
'{',
' if (Test-Path -LiteralPath "$PSScriptRoot\\..\\..\\..\\..\\PSAppDeployToolkit\\PSAppDeployToolkit.psd1" -PathType Leaf)',
' {',
' Get-ChildItem -LiteralPath "$PSScriptRoot\\..\\..\\..\\..\\PSAppDeployToolkit" -Recurse -File | Unblock-File -ErrorAction Ignore',
' Import-Module -FullyQualifiedName @{ ModuleName = [System.Management.Automation.WildcardPattern]::Escape("$PSScriptRoot\\..\\..\\..\\..\\PSAppDeployToolkit\\PSAppDeployToolkit.psd1"); Guid = \'8c3c366b-8606-4576-9f2d-4051144f7ca2\'; ModuleVersion = \'4.2.0\' } -Force',
' }',
' else',
' {',
" Import-Module -FullyQualifiedName @{ ModuleName = 'PSAppDeployToolkit'; Guid = '8c3c366b-8606-4576-9f2d-4051144f7ca2'; ModuleVersion = '4.2.0' } -Force",
' }',
' $iadtParams = Get-ADTBoundParametersAndDefaultValues -Invocation $MyInvocation',
' $adtSession = Remove-ADTHashtableNullOrEmptyValues -Hashtable $adtSession',
' $adtSession = Open-ADTSession @adtSession @iadtParams -PassThru',
' Remove-Variable -Name iadtParams -Force -Confirm:$false',
'}',
'catch',
'{',
' $Host.UI.WriteErrorLine((Out-String -InputObject $_ -Width ([System.Int16]::MaxValue)))',
' exit 60008',
'}',
'',
'## MARK: Invocation',
'try',
'{',
' Get-ChildItem -LiteralPath $PSScriptRoot -Directory | & {',
' process',
' {',
" if ($_.Name -match 'PSAppDeployToolkit\\..+$')",
' {',
' Get-ChildItem -LiteralPath $_.FullName -Recurse -File | Unblock-File -ErrorAction Ignore',
' Import-Module -Name ([System.Management.Automation.WildcardPattern]::Escape("$($_.FullName)\\$($_.BaseName).psd1")) -Force',
' }',
' }',
' }',
' Get-Variable -Name "Pre-$($adtSession.DeploymentType)", $adtSession.DeploymentType, "Post-$($adtSession.DeploymentType)" -ErrorAction Ignore | . {',
' process',
' {',
' if (![System.String]::IsNullOrWhiteSpace($_.Value))',
' {',
' $adtSession.InstallPhase = $_.Name',
' . $_.Value',
' }',
' }',
' }',
' Close-ADTSession',
'}',
'catch',
'{',
' Write-ADTLogEntry -Message "An unhandled error within [$($MyInvocation.MyCommand.Name)] has occurred.`n$(Resolve-ADTErrorRecord -ErrorRecord $_)" -Severity Error',
' Close-ADTSession -ExitCode 60001',
'}',
''
].join('\n');
}
function defaultInstallTasks(profile) {
if (profile.zeroConfig) return [{ type: 'msi', phase: 'Install', filePath: 'Files\\SingleInstaller.msi', arguments: '', secureArguments: false }];
return [{ type: 'exe', phase: 'Install', filePath: 'setup.exe', arguments: '/S', secureArguments: false }];
}
function renderTask(task) {
if (task.type === 'msi' || task.type === 'msp') {
const action = task.type === 'msp' ? 'Patch' : task.phase.replace(/^Pre-/, '').replace(/^Post-/, '').replace('Install', 'Install');
const args = task.arguments ? ` -AdditionalArgumentList '${ps(task.arguments)}'` : '';
return `Start-ADTMsiProcess -Action '${ps(action)}' -FilePath '${ps(task.filePath)}'${args}${task.secureArguments ? ' -SecureArgumentList' : ''}`;
}
return `Start-ADTProcess -FilePath '${ps(task.filePath)}'${task.arguments ? ` -ArgumentList '${ps(task.arguments)}'` : ''} -WaitForChildProcesses${task.secureArguments ? ' -SecureArgumentList' : ''}`;
}
function ps(value) {
return String(value || '').replace(/'/g, "''");
}