Initial
This commit is contained in:
78
server/services/appVersionService.js
Normal file
78
server/services/appVersionService.js
Normal file
@@ -0,0 +1,78 @@
|
||||
// Application version comparison + upstream version lookup for the Phase 5
|
||||
// catalog. The comparison and Graph/winget mapping are pure and unit-tested; the
|
||||
// network lookups (`fetchLatestVersion`) require live sources and are marked
|
||||
// needs-live-verification.
|
||||
|
||||
// Compare two dotted version strings numerically with lexical fallback for
|
||||
// non-numeric segments. Returns -1 (a<b), 0 (equal), or 1 (a>b).
|
||||
export function compareVersions(a = '', b = '') {
|
||||
const norm = (v) => String(v).trim().replace(/^v/i, '').split(/[.\-+]/).filter(Boolean);
|
||||
const pa = norm(a);
|
||||
const pb = norm(b);
|
||||
const len = Math.max(pa.length, pb.length);
|
||||
for (let i = 0; i < len; i++) {
|
||||
const sa = pa[i] ?? '0';
|
||||
const sb = pb[i] ?? '0';
|
||||
const na = Number(sa);
|
||||
const nb = Number(sb);
|
||||
const bothNumeric = Number.isFinite(na) && Number.isFinite(nb) && /^\d+$/.test(sa) && /^\d+$/.test(sb);
|
||||
let cmp;
|
||||
if (bothNumeric) cmp = na === nb ? 0 : (na < nb ? -1 : 1);
|
||||
else cmp = sa === sb ? 0 : (sa < sb ? -1 : 1);
|
||||
if (cmp !== 0) return cmp;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Decide whether a catalog entry has a newer upstream version than what is
|
||||
// currently published.
|
||||
export function evaluateUpdateState({ currentVersion, latestKnownVersion } = {}) {
|
||||
const current = currentVersion || '';
|
||||
const latest = latestKnownVersion || '';
|
||||
if (!latest) return { updateAvailable: false, current, latest, reason: 'no upstream version known' };
|
||||
if (!current) return { updateAvailable: true, current, latest, reason: 'no current version recorded' };
|
||||
const cmp = compareVersions(latest, current);
|
||||
return {
|
||||
updateAvailable: cmp > 0,
|
||||
current,
|
||||
latest,
|
||||
reason: cmp > 0 ? 'newer upstream version available' : 'up to date'
|
||||
};
|
||||
}
|
||||
|
||||
// Pure mapping from a Graph mobileApp (win32LobApp) to catalog fields, used when
|
||||
// adopting an existing tenant app into the catalog.
|
||||
export function mapGraphAppToCatalog(graphApp = {}) {
|
||||
return {
|
||||
name: graphApp.displayName || graphApp.name || graphApp.id || 'Imported app',
|
||||
vendor: graphApp.publisher || '',
|
||||
publisher: graphApp.publisher || '',
|
||||
currentVersion: graphApp.displayVersion || graphApp.version || '',
|
||||
currentGraphAppId: graphApp.id || ''
|
||||
};
|
||||
}
|
||||
|
||||
// Look up the latest upstream version for a catalog entry. `manual` sources are
|
||||
// operator-driven; `url` expects JSON with a `version` field; `winget` queries a
|
||||
// community winget index (configurable ref). Network-dependent — verify live.
|
||||
export async function fetchLatestVersion({ versionSource = 'manual', versionSourceRef = '' } = {}) {
|
||||
if (versionSource === 'manual' || !versionSourceRef) {
|
||||
return { version: '', source: 'manual', message: 'Manual source; set the latest version by hand.' };
|
||||
}
|
||||
if (versionSource === 'url') {
|
||||
const res = await fetch(versionSourceRef, { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error(`Version URL returned ${res.status}`);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const version = data.version || data.latestVersion || data.tag_name || '';
|
||||
return { version: String(version).replace(/^v/i, ''), source: 'url', message: version ? '' : 'No version field found in URL response.' };
|
||||
}
|
||||
if (versionSource === 'winget') {
|
||||
// Community winget index (e.g. https://api.winget.run/v2/packages/<id>).
|
||||
const res = await fetch(versionSourceRef, { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error(`winget source returned ${res.status}`);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const version = data?.Package?.Latest?.PackageVersion || data?.latestVersion || data?.version || '';
|
||||
return { version: String(version), source: 'winget', message: version ? '' : 'No version found in winget response.' };
|
||||
}
|
||||
throw new Error(`Unknown version source "${versionSource}"`);
|
||||
}
|
||||
49
server/services/catalogWorker.js
Normal file
49
server/services/catalogWorker.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { applyVersionCheck, listAutoCheckApplications } from '../models/applicationModel.js';
|
||||
import { fetchLatestVersion } from './appVersionService.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
// Background upstream-version checker for catalog applications that opted in via
|
||||
// `autoCheck`. The runner is dependency-injected (fetcher) so it can be tested
|
||||
// without network access; the scheduler wires in the real fetchLatestVersion.
|
||||
|
||||
export async function runCatalogChecks({ fetcher = fetchLatestVersion } = {}) {
|
||||
const apps = listAutoCheckApplications();
|
||||
const summary = { checked: 0, updated: 0, errors: 0 };
|
||||
for (const app of apps) {
|
||||
summary.checked += 1;
|
||||
try {
|
||||
const result = await fetcher(app);
|
||||
const next = result.version || '';
|
||||
const changed = next && next !== app.latestKnownVersion;
|
||||
applyVersionCheck(app.id, { latestKnownVersion: next || app.latestKnownVersion, message: result.message || '' });
|
||||
if (changed) summary.updated += 1;
|
||||
} catch (error) {
|
||||
summary.errors += 1;
|
||||
applyVersionCheck(app.id, { latestKnownVersion: app.latestKnownVersion, message: error.message });
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
let timer = null;
|
||||
|
||||
// Start the recurring worker. Disabled unless CATALOG_CHECK_INTERVAL_MINUTES > 0
|
||||
// so installs never make surprise outbound calls.
|
||||
export function startCatalogWorker() {
|
||||
const minutes = Number(process.env.CATALOG_CHECK_INTERVAL_MINUTES || 0);
|
||||
if (!Number.isFinite(minutes) || minutes <= 0) return null;
|
||||
const intervalMs = minutes * 60 * 1000;
|
||||
timer = setInterval(() => {
|
||||
runCatalogChecks()
|
||||
.then((summary) => logger.info('catalog auto-check complete', summary))
|
||||
.catch((error) => logger.error('catalog auto-check failed', { error: error.message }));
|
||||
}, intervalMs);
|
||||
timer.unref?.();
|
||||
logger.info(`catalog auto-check scheduled every ${minutes} minute(s)`);
|
||||
return timer;
|
||||
}
|
||||
|
||||
export function stopCatalogWorker() {
|
||||
if (timer) clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
35
server/services/cryptoStore.js
Normal file
35
server/services/cryptoStore.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { config } from '../config.js';
|
||||
|
||||
function keyBuffer() {
|
||||
// Derive fixed-length AES key material from the configured secret-like environment value.
|
||||
return crypto.createHash('sha256').update(config.credentialStoreKey).digest();
|
||||
}
|
||||
|
||||
export function encryptSecret(value = '') {
|
||||
// AES-GCM stores cipher text plus IV/tag so reads can expose metadata without returning secrets.
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', keyBuffer(), iv);
|
||||
const encrypted = Buffer.concat([cipher.update(String(value), 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return {
|
||||
cipher: encrypted.toString('base64'),
|
||||
iv: iv.toString('base64'),
|
||||
tag: tag.toString('base64')
|
||||
};
|
||||
}
|
||||
|
||||
export function decryptSecret(record) {
|
||||
// Missing cipher fields mean the credential has no retrievable secret rather than throwing.
|
||||
if (!record?.secret_cipher || !record?.secret_iv || !record?.secret_tag) return '';
|
||||
const decipher = crypto.createDecipheriv(
|
||||
'aes-256-gcm',
|
||||
keyBuffer(),
|
||||
Buffer.from(record.secret_iv, 'base64')
|
||||
);
|
||||
decipher.setAuthTag(Buffer.from(record.secret_tag, 'base64'));
|
||||
return Buffer.concat([
|
||||
decipher.update(Buffer.from(record.secret_cipher, 'base64')),
|
||||
decipher.final()
|
||||
]).toString('utf8');
|
||||
}
|
||||
66
server/services/detectionRuleService.js
Normal file
66
server/services/detectionRuleService.js
Normal file
@@ -0,0 +1,66 @@
|
||||
// Generate a "software footprint" detection rule from simple inputs, in the
|
||||
// shape the deployment model already uses (`detectionType` + `detectionRule`).
|
||||
// MSI maps to a product-code rule; file/registry produce Intune-style custom
|
||||
// PowerShell detection scripts so they flow straight through publishing without
|
||||
// extra mapping. Pure + unit-tested.
|
||||
|
||||
function fileDetectionScript({ path, version }) {
|
||||
const lines = [`$path = '${path.replace(/'/g, "''")}'`, 'if (Test-Path -LiteralPath $path) {'];
|
||||
if (version) {
|
||||
lines.push(" $found = (Get-Item -LiteralPath $path).VersionInfo.ProductVersion");
|
||||
lines.push(` if ($found -and [version]$found -ge [version]'${version}') { Write-Output 'Detected'; exit 0 }`);
|
||||
} else {
|
||||
lines.push(" Write-Output 'Detected'; exit 0");
|
||||
}
|
||||
lines.push('}');
|
||||
lines.push('exit 1');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function registryDetectionScript({ keyPath, valueName, valueData }) {
|
||||
const key = keyPath.replace(/'/g, "''");
|
||||
const lines = [`$key = '${key}'`];
|
||||
if (valueName) {
|
||||
lines.push("$value = (Get-ItemProperty -LiteralPath $key -ErrorAction SilentlyContinue).'" + valueName.replace(/'/g, "''") + "'");
|
||||
if (valueData) {
|
||||
lines.push(`if ($value -eq '${valueData.replace(/'/g, "''")}') { Write-Output 'Detected'; exit 0 }`);
|
||||
} else {
|
||||
lines.push("if ($null -ne $value) { Write-Output 'Detected'; exit 0 }");
|
||||
}
|
||||
} else {
|
||||
lines.push("if (Test-Path -LiteralPath $key) { Write-Output 'Detected'; exit 0 }");
|
||||
}
|
||||
lines.push('exit 1');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function buildDetectionRule(input = {}) {
|
||||
switch (input.type) {
|
||||
case 'msi': {
|
||||
if (!input.productCode) throw new Error('productCode is required for MSI detection');
|
||||
return {
|
||||
detectionType: 'msi-product-code',
|
||||
detectionRule: input.productCode,
|
||||
description: `MSI product code ${input.productCode}`
|
||||
};
|
||||
}
|
||||
case 'file': {
|
||||
if (!input.path) throw new Error('path is required for file detection');
|
||||
return {
|
||||
detectionType: 'custom-script',
|
||||
detectionRule: fileDetectionScript(input),
|
||||
description: input.version ? `File ${input.path} version ≥ ${input.version}` : `File exists: ${input.path}`
|
||||
};
|
||||
}
|
||||
case 'registry': {
|
||||
if (!input.keyPath) throw new Error('keyPath is required for registry detection');
|
||||
return {
|
||||
detectionType: 'custom-script',
|
||||
detectionRule: registryDetectionScript(input),
|
||||
description: input.valueName ? `Registry ${input.keyPath}\\${input.valueName}` : `Registry key ${input.keyPath}`
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown detection type "${input.type}" (expected msi, file, or registry)`);
|
||||
}
|
||||
}
|
||||
87
server/services/entraService.js
Normal file
87
server/services/entraService.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { config } from '../config.js';
|
||||
import { getSetting, getBooleanSetting } from '../models/settingsModel.js';
|
||||
|
||||
// Microsoft Entra ID (Azure AD) OAuth 2.0 authorization-code login.
|
||||
//
|
||||
// Tenant/client/callback values are read from the effective settings so admins
|
||||
// can adjust them in the Config screen, while the client secret stays in the
|
||||
// process environment (never persisted to the settings table). The flow uses
|
||||
// Node's global fetch and the existing jsonwebtoken dependency for signed
|
||||
// state, so no new packages are required.
|
||||
|
||||
const AUTHORITY_HOST = 'https://login.microsoftonline.com';
|
||||
const GRAPH_ME_URL = 'https://graph.microsoft.com/v1.0/me';
|
||||
const LOGIN_SCOPE = 'openid profile email https://graph.microsoft.com/User.Read';
|
||||
|
||||
export function entraSettings() {
|
||||
return {
|
||||
enabled: getBooleanSetting('entra_enabled', config.entra.enabled),
|
||||
tenantId: getSetting('entra_tenant_id') || config.entra.tenantId,
|
||||
clientId: getSetting('entra_client_id') || config.entra.clientId,
|
||||
clientSecret: config.entra.clientSecret,
|
||||
callbackUrl: getSetting('entra_callback_url') || config.entra.callbackUrl
|
||||
};
|
||||
}
|
||||
|
||||
// Login is only offered when Entra is enabled AND fully configured. Missing any
|
||||
// piece (common in dev) means we fall back to local auth without erroring.
|
||||
export function isEntraLoginConfigured() {
|
||||
const s = entraSettings();
|
||||
return Boolean(s.enabled && s.tenantId && s.clientId && s.clientSecret && s.callbackUrl);
|
||||
}
|
||||
|
||||
export function signState(payload = {}) {
|
||||
return jwt.sign({ ...payload, purpose: 'entra-state' }, config.jwtSecret, { expiresIn: '10m' });
|
||||
}
|
||||
|
||||
export function verifyState(state) {
|
||||
const decoded = jwt.verify(state, config.jwtSecret);
|
||||
if (decoded.purpose !== 'entra-state') throw new Error('Invalid state');
|
||||
return decoded;
|
||||
}
|
||||
|
||||
export function buildAuthorizeUrl(state) {
|
||||
const s = entraSettings();
|
||||
const params = new URLSearchParams({
|
||||
client_id: s.clientId,
|
||||
response_type: 'code',
|
||||
redirect_uri: s.callbackUrl,
|
||||
response_mode: 'query',
|
||||
scope: LOGIN_SCOPE,
|
||||
state
|
||||
});
|
||||
return `${AUTHORITY_HOST}/${encodeURIComponent(s.tenantId)}/oauth2/v2.0/authorize?${params.toString()}`;
|
||||
}
|
||||
|
||||
export async function exchangeCodeForToken(code) {
|
||||
const s = entraSettings();
|
||||
const response = await fetch(`${AUTHORITY_HOST}/${encodeURIComponent(s.tenantId)}/oauth2/v2.0/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: s.clientId,
|
||||
client_secret: s.clientSecret,
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: s.callbackUrl,
|
||||
scope: LOGIN_SCOPE
|
||||
})
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error_description || payload.error || `Token exchange failed: ${response.status}`);
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function fetchEntraProfile(accessToken) {
|
||||
const response = await fetch(GRAPH_ME_URL, { headers: { Authorization: `Bearer ${accessToken}` } });
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload?.error?.message || `Profile lookup failed: ${response.status}`);
|
||||
const email = (payload.mail || payload.userPrincipalName || '').toLowerCase();
|
||||
if (!email) throw new Error('Entra profile did not include an email or user principal name');
|
||||
return {
|
||||
email,
|
||||
displayName: payload.displayName || email,
|
||||
oid: payload.id || ''
|
||||
};
|
||||
}
|
||||
28
server/services/graphRbac.js
Normal file
28
server/services/graphRbac.js
Normal file
@@ -0,0 +1,28 @@
|
||||
// Pure per-connection RBAC checks for Intune tenant write-back. A connection may
|
||||
// name explicit publishers (who may trigger writes) and approvers (who may
|
||||
// approve change requests). Admins always pass. Empty lists mean "no extra
|
||||
// restriction" — the allow_write / require_approval gates still apply.
|
||||
|
||||
function asList(value) {
|
||||
if (Array.isArray(value)) return value;
|
||||
try {
|
||||
const parsed = JSON.parse(value || '[]');
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function canPublish(connection, user) {
|
||||
if (!user) return false;
|
||||
if (user.role === 'admin') return true;
|
||||
const publishers = asList(connection?.publisher_ids ?? connection?.publisherIds);
|
||||
return publishers.length === 0 || publishers.includes(user.id);
|
||||
}
|
||||
|
||||
export function canApprove(connection, user) {
|
||||
if (!user) return false;
|
||||
if (user.role === 'admin') return true;
|
||||
const approvers = asList(connection?.approver_ids ?? connection?.approverIds);
|
||||
return approvers.includes(user.id);
|
||||
}
|
||||
423
server/services/graphService.js
Normal file
423
server/services/graphService.js
Normal file
@@ -0,0 +1,423 @@
|
||||
import { isAllowedAuthorityHost, isAllowedGraphHost } from '../data/graphHosts.js';
|
||||
|
||||
function sanitizeBase(url = '') {
|
||||
return String(url || '').replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
// Defense in depth: even though connection URLs are validated on write, re-check
|
||||
// before every outbound request so stored/legacy rows can't trigger SSRF.
|
||||
function assertAllowedEndpoints(connection) {
|
||||
if (!isAllowedAuthorityHost(connection.authority_host)) {
|
||||
throw new Error('Graph connection authority host is not an allowed Microsoft endpoint');
|
||||
}
|
||||
if (!isAllowedGraphHost(connection.graph_base_url)) {
|
||||
throw new Error('Graph connection base URL is not an allowed Microsoft endpoint');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGraphToken(connection) {
|
||||
assertAllowedEndpoints(connection);
|
||||
const authority = sanitizeBase(connection.authority_host);
|
||||
const graphBase = sanitizeBase(connection.graph_base_url);
|
||||
const response = await fetch(`${authority}/${encodeURIComponent(connection.tenant_id)}/oauth2/v2.0/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: connection.client_id,
|
||||
client_secret: connection.clientSecret,
|
||||
scope: `${graphBase}/.default`,
|
||||
grant_type: 'client_credentials'
|
||||
})
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error_description || payload.error || `Token request failed: ${response.status}`);
|
||||
return payload.access_token;
|
||||
}
|
||||
|
||||
export async function graphRequest(connection, path, options = {}) {
|
||||
assertAllowedEndpoints(connection);
|
||||
const token = options.token || await getGraphToken(connection);
|
||||
const base = sanitizeBase(connection.graph_base_url);
|
||||
const response = await fetch(`${base}${path}`, {
|
||||
method: options.method || 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/json',
|
||||
...(options.body ? { 'Content-Type': 'application/json' } : {})
|
||||
},
|
||||
body: options.body ? JSON.stringify(options.body) : undefined
|
||||
});
|
||||
const text = await response.text();
|
||||
const payload = text ? JSON.parse(text) : {};
|
||||
if (!response.ok) {
|
||||
const message = payload?.error?.message || payload?.error_description || `Graph request failed: ${response.status}`;
|
||||
const error = new Error(message);
|
||||
error.status = response.status;
|
||||
error.payload = payload;
|
||||
throw error;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function testGraphConnection(connection) {
|
||||
const token = await getGraphToken(connection);
|
||||
const payload = await graphRequest(connection, '/v1.0/deviceAppManagement?$select=id&$top=1', { token });
|
||||
return { ok: true, message: `Connected. deviceAppManagement readable; sample keys: ${Object.keys(payload).join(', ')}` };
|
||||
}
|
||||
|
||||
// Create or update the win32LobApp metadata object. Content (.intunewin bytes)
|
||||
// upload is a separate Phase 2 concern; this writes the app definition only.
|
||||
// mobileApps create/update lives on the beta endpoint for full win32 fidelity.
|
||||
export async function upsertWin32App(connection, payload, existingAppId = '') {
|
||||
const token = await getGraphToken(connection);
|
||||
if (existingAppId) {
|
||||
await graphRequest(connection, `/beta/deviceAppManagement/mobileApps/${encodeURIComponent(existingAppId)}`, {
|
||||
token,
|
||||
method: 'PATCH',
|
||||
body: payload
|
||||
});
|
||||
return { id: existingAppId, updated: true };
|
||||
}
|
||||
const created = await graphRequest(connection, '/beta/deviceAppManagement/mobileApps', {
|
||||
token,
|
||||
method: 'POST',
|
||||
body: payload
|
||||
});
|
||||
return { id: created.id, created: true, raw: created };
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
async function waitForFileState(connection, token, appPath, cvId, fileId, target, { attempts = 30, delayMs = 2000 } = {}) {
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
const file = await graphRequest(connection, `${appPath}/contentVersions/${cvId}/files/${fileId}`, { token });
|
||||
const state = file.uploadState || '';
|
||||
if (state === target) return file;
|
||||
if (state.endsWith('Failed')) throw new Error(`Intune content upload failed in state "${state}"`);
|
||||
await sleep(delayMs);
|
||||
}
|
||||
throw new Error(`Timed out waiting for Intune content state "${target}"`);
|
||||
}
|
||||
|
||||
// Upload encrypted .intunewin content to Azure Storage in blocks, then commit.
|
||||
// The SAS URI is returned by Graph (trusted); the block PUTs go straight to
|
||||
// Azure Blob storage, outside the Graph allow-list.
|
||||
async function azureBlockUpload(sasUri, content) {
|
||||
const blockSize = 6 * 1024 * 1024;
|
||||
const blockIds = [];
|
||||
let index = 0;
|
||||
for (let start = 0; start < content.length; start += blockSize) {
|
||||
const chunk = content.subarray(start, start + blockSize);
|
||||
const blockId = Buffer.from(String(index).padStart(8, '0')).toString('base64');
|
||||
blockIds.push(blockId);
|
||||
const res = await fetch(`${sasUri}&comp=block&blockid=${encodeURIComponent(blockId)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'x-ms-blob-type': 'BlockBlob' },
|
||||
body: chunk
|
||||
});
|
||||
if (!res.ok) throw new Error(`Azure block upload failed (${res.status}) on block ${index}`);
|
||||
index += 1;
|
||||
}
|
||||
const listXml = `<?xml version="1.0" encoding="utf-8"?><BlockList>${blockIds.map((b) => `<Latest>${b}</Latest>`).join('')}</BlockList>`;
|
||||
const commit = await fetch(`${sasUri}&comp=blocklist`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'text/plain; charset=UTF-8' },
|
||||
body: listXml
|
||||
});
|
||||
if (!commit.ok) throw new Error(`Azure block-list commit failed (${commit.status})`);
|
||||
}
|
||||
|
||||
// Full Win32 content commit flow (Phase 2). Requires a live tenant + Azure
|
||||
// Storage; validated end to end only against a real tenant. `parsed` is the
|
||||
// output of parseIntunewin().
|
||||
export async function uploadWin32Content(connection, appId, parsed) {
|
||||
const token = await getGraphToken(connection);
|
||||
const appPath = `/beta/deviceAppManagement/mobileApps/${encodeURIComponent(appId)}/microsoft.graph.win32LobApp`;
|
||||
|
||||
const version = await graphRequest(connection, `${appPath}/contentVersions`, { token, method: 'POST', body: {} });
|
||||
const cvId = version.id;
|
||||
|
||||
const fileMeta = await graphRequest(connection, `${appPath}/contentVersions/${cvId}/files`, {
|
||||
token,
|
||||
method: 'POST',
|
||||
body: {
|
||||
'@odata.type': '#microsoft.graph.mobileAppContentFile',
|
||||
name: parsed.setupFile || 'IntunePackage.intunewin',
|
||||
size: parsed.unencryptedContentSize,
|
||||
sizeEncrypted: parsed.encryptedContentSize,
|
||||
isDependency: false,
|
||||
manifest: null
|
||||
}
|
||||
});
|
||||
const fileId = fileMeta.id;
|
||||
|
||||
const ready = await waitForFileState(connection, token, appPath, cvId, fileId, 'azureStorageUriRequestSuccess');
|
||||
await azureBlockUpload(ready.azureStorageUri, parsed.encryptedContent);
|
||||
|
||||
await graphRequest(connection, `${appPath}/contentVersions/${cvId}/files/${fileId}/commit`, {
|
||||
token,
|
||||
method: 'POST',
|
||||
body: { fileEncryptionInfo: parsed.fileEncryptionInfo }
|
||||
});
|
||||
await waitForFileState(connection, token, appPath, cvId, fileId, 'commitFileSuccess');
|
||||
|
||||
await graphRequest(connection, `/beta/deviceAppManagement/mobileApps/${encodeURIComponent(appId)}`, {
|
||||
token,
|
||||
method: 'PATCH',
|
||||
body: { '@odata.type': '#microsoft.graph.win32LobApp', committedContentVersion: cvId }
|
||||
});
|
||||
|
||||
return { contentVersionId: cvId, fileId, size: parsed.unencryptedContentSize, sizeEncrypted: parsed.encryptedContentSize };
|
||||
}
|
||||
|
||||
// Replace the app's assignments in one call. The /assign action is a full
|
||||
// replacement, so callers send the complete desired assignment set.
|
||||
export async function assignWin32App(connection, appId, mobileAppAssignments) {
|
||||
const token = await getGraphToken(connection);
|
||||
await graphRequest(connection, `/beta/deviceAppManagement/mobileApps/${encodeURIComponent(appId)}/assign`, {
|
||||
token,
|
||||
method: 'POST',
|
||||
body: { mobileAppAssignments }
|
||||
});
|
||||
return { ok: true, count: mobileAppAssignments.length };
|
||||
}
|
||||
|
||||
// Replace the app's supersedence/dependency relationships. updateRelationships
|
||||
// is also a full replacement of the app's outgoing relationship set.
|
||||
export async function setWin32AppRelationships(connection, appId, relationships) {
|
||||
const token = await getGraphToken(connection);
|
||||
await graphRequest(connection, `/beta/deviceAppManagement/mobileApps/${encodeURIComponent(appId)}/updateRelationships`, {
|
||||
token,
|
||||
method: 'POST',
|
||||
body: { relationships }
|
||||
});
|
||||
return { ok: true, count: relationships.length };
|
||||
}
|
||||
|
||||
// Live win32LobApp metadata + assignments + relationships, used by drift checks.
|
||||
export async function getWin32AppState(connection, appId) {
|
||||
const token = await getGraphToken(connection);
|
||||
const base = `/beta/deviceAppManagement/mobileApps/${encodeURIComponent(appId)}`;
|
||||
const [app, assignments, relationships] = await Promise.all([
|
||||
graphRequest(connection, base, { token }),
|
||||
graphRequest(connection, `${base}/assignments`, { token }).then((r) => r.value || []).catch(() => []),
|
||||
graphRequest(connection, `${base}/relationships`, { token }).then((r) => r.value || []).catch(() => [])
|
||||
]);
|
||||
return { app, assignments, relationships };
|
||||
}
|
||||
|
||||
export async function getGraphMobileApp(connection, appId) {
|
||||
const token = await getGraphToken(connection);
|
||||
const version = connection.default_api_version || 'v1.0';
|
||||
return graphRequest(
|
||||
connection,
|
||||
`/${version}/deviceAppManagement/mobileApps/${encodeURIComponent(appId)}?$select=id,displayName,publisher,displayVersion`,
|
||||
{ token }
|
||||
);
|
||||
}
|
||||
|
||||
export async function listGraphMobileApps(connection, query = '') {
|
||||
const token = await getGraphToken(connection);
|
||||
const version = connection.default_api_version || 'v1.0';
|
||||
const filter = query ? `&$filter=contains(displayName,'${String(query).replaceAll("'", "''")}')` : '';
|
||||
const payload = await graphRequest(
|
||||
connection,
|
||||
`/${version}/deviceAppManagement/mobileApps?$select=id,displayName,publisher,createdDateTime,lastModifiedDateTime,isAssigned${filter}&$top=50`,
|
||||
{ token }
|
||||
);
|
||||
return (payload.value || []).map((app) => ({
|
||||
id: app.id,
|
||||
displayName: app.displayName || app.name || app.id,
|
||||
publisher: app.publisher || '',
|
||||
createdDateTime: app.createdDateTime || '',
|
||||
lastModifiedDateTime: app.lastModifiedDateTime || '',
|
||||
isAssigned: Boolean(app.isAssigned)
|
||||
}));
|
||||
}
|
||||
|
||||
export async function syncIntuneDeploymentStatus(connection, graphAppId, mode = 'reports') {
|
||||
const token = await getGraphToken(connection);
|
||||
const rows = [];
|
||||
const sources = [];
|
||||
let overview = {};
|
||||
|
||||
if (mode === 'reports' || mode === 'both') {
|
||||
const reportResult = await syncReports(connection, token, graphAppId);
|
||||
rows.push(...reportResult.rows);
|
||||
overview = reportResult.overview;
|
||||
sources.push(...reportResult.sources);
|
||||
}
|
||||
|
||||
if ((mode === 'legacy-status' || mode === 'both') && !rows.length) {
|
||||
const legacyRows = await syncLegacyDeviceStatuses(connection, token, graphAppId);
|
||||
rows.push(...legacyRows);
|
||||
sources.push('legacy-deviceStatuses');
|
||||
}
|
||||
|
||||
return {
|
||||
rows,
|
||||
overview,
|
||||
sources,
|
||||
summary: summarizeRows(rows, overview)
|
||||
};
|
||||
}
|
||||
|
||||
async function syncReports(connection, token, graphAppId) {
|
||||
const rows = [];
|
||||
const sources = [];
|
||||
let overview = {};
|
||||
try {
|
||||
const appOverview = await graphRequest(connection, '/beta/deviceManagement/reports/getAppStatusOverviewReport', {
|
||||
token,
|
||||
method: 'POST',
|
||||
body: { filter: `(ApplicationId eq '${graphAppId}')` }
|
||||
});
|
||||
overview = reportRows(appOverview)[0] || {};
|
||||
sources.push('getAppStatusOverviewReport');
|
||||
} catch (error) {
|
||||
overview = { warning: `Overview report unavailable: ${error.message}` };
|
||||
}
|
||||
|
||||
try {
|
||||
const deviceReport = await graphRequest(connection, '/beta/deviceManagement/reports/getDeviceInstallStatusReport', {
|
||||
token,
|
||||
method: 'POST',
|
||||
body: {
|
||||
select: [
|
||||
'DeviceId',
|
||||
'DeviceName',
|
||||
'UserPrincipalName',
|
||||
'Platform',
|
||||
'Status',
|
||||
'ErrorCode',
|
||||
'AppInstallState',
|
||||
'InstallState',
|
||||
'InstallStateDetail',
|
||||
'LastModifiedDateTime',
|
||||
'ApplicationId'
|
||||
],
|
||||
filter: `(ApplicationId eq '${graphAppId}')`,
|
||||
skip: 0,
|
||||
top: 50000,
|
||||
orderBy: []
|
||||
}
|
||||
});
|
||||
rows.push(...reportRows(deviceReport).map((row) => normalizeReportStatus(row, 'device')));
|
||||
sources.push('getDeviceInstallStatusReport');
|
||||
} catch (error) {
|
||||
rows.push(failureRow('device', 'report-unavailable', error.message, { source: 'getDeviceInstallStatusReport' }));
|
||||
}
|
||||
|
||||
try {
|
||||
const userReport = await graphRequest(connection, '/beta/deviceManagement/reports/getUserInstallStatusReport', {
|
||||
token,
|
||||
method: 'POST',
|
||||
body: {
|
||||
select: ['UserId', 'UserName', 'UserPrincipalName', 'Status', 'InstalledDeviceCount', 'FailedDeviceCount', 'ApplicationId'],
|
||||
filter: `(ApplicationId eq '${graphAppId}')`,
|
||||
skip: 0,
|
||||
top: 50000,
|
||||
orderBy: []
|
||||
}
|
||||
});
|
||||
rows.push(...reportRows(userReport).map((row) => normalizeReportStatus(row, 'user')));
|
||||
sources.push('getUserInstallStatusReport');
|
||||
} catch (error) {
|
||||
rows.push(failureRow('user', 'report-unavailable', error.message, { source: 'getUserInstallStatusReport' }));
|
||||
}
|
||||
|
||||
return { rows, overview, sources };
|
||||
}
|
||||
|
||||
async function syncLegacyDeviceStatuses(connection, token, graphAppId) {
|
||||
const payload = await graphRequest(connection, `/beta/deviceAppManagement/mobileApps/${graphAppId}/deviceStatuses`, { token });
|
||||
return (payload.value || []).map((row) => ({
|
||||
scope: 'device',
|
||||
principalId: row.id || '',
|
||||
principalName: row.deviceName || row.userName || '',
|
||||
deviceId: row.deviceId || '',
|
||||
deviceName: row.deviceName || '',
|
||||
userId: row.userId || '',
|
||||
userPrincipalName: row.userPrincipalName || row.userName || '',
|
||||
installState: normalizeState(row.installState || row.mobileAppInstallStatusValue || row.status),
|
||||
installStateDetail: row.installStateDetail || '',
|
||||
errorCode: row.errorCode,
|
||||
errorDescription: row.errorDescription || failureCause(row.errorCode, row.installStateDetail),
|
||||
lastSyncDateTime: row.lastSyncDateTime || '',
|
||||
raw: row
|
||||
}));
|
||||
}
|
||||
|
||||
function reportRows(report) {
|
||||
const schema = report.Schema || report.schema || [];
|
||||
const values = report.Values || report.values || report.value || [];
|
||||
if (!Array.isArray(values)) return [];
|
||||
if (!schema.length) return values;
|
||||
return values.map((entry) => {
|
||||
if (!Array.isArray(entry)) return entry;
|
||||
return Object.fromEntries(schema.map((column, index) => [column.Column || column.column || column.Name || column.name, entry[index]]));
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeReportStatus(row, scope) {
|
||||
const installState = normalizeState(row.Status || row.InstallState || row.AppInstallState || row.State || row.InstallStatus);
|
||||
const errorCode = row.ErrorCode ?? row.Error ?? row.HexErrorCode ?? '';
|
||||
return {
|
||||
scope,
|
||||
principalId: scope === 'device' ? row.DeviceId || row.ManagedDeviceId || '' : row.UserId || '',
|
||||
principalName: scope === 'device' ? row.DeviceName || '' : row.UserName || row.UserPrincipalName || '',
|
||||
deviceId: row.DeviceId || row.ManagedDeviceId || '',
|
||||
deviceName: row.DeviceName || '',
|
||||
userId: row.UserId || '',
|
||||
userPrincipalName: row.UserPrincipalName || row.UserName || '',
|
||||
installState,
|
||||
installStateDetail: row.InstallStateDetail || row.StatusDetails || row.StateDetails || '',
|
||||
errorCode,
|
||||
errorDescription: row.ErrorDescription || row.ErrorDetails || failureCause(errorCode, row.InstallStateDetail),
|
||||
lastSyncDateTime: row.LastModifiedDateTime || row.LastSyncDateTime || row.LastReportedDateTime || '',
|
||||
raw: row
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeState(value = '') {
|
||||
const text = String(value || '').toLowerCase();
|
||||
if (text.includes('fail') || text === 'error') return 'failed';
|
||||
if (text.includes('install') || text === 'success' || text === 'installed') return 'installed';
|
||||
if (text.includes('pending') || text.includes('progress')) return 'pending';
|
||||
if (text.includes('notapplicable') || text.includes('not applicable')) return 'notApplicable';
|
||||
if (text.includes('notinstalled') || text.includes('not installed')) return 'notInstalled';
|
||||
return text || 'unknown';
|
||||
}
|
||||
|
||||
function failureRow(scope, code, message, raw) {
|
||||
return {
|
||||
scope,
|
||||
installState: 'failed',
|
||||
installStateDetail: code,
|
||||
errorCode: code,
|
||||
errorDescription: message,
|
||||
raw
|
||||
};
|
||||
}
|
||||
|
||||
function failureCause(errorCode, detail = '') {
|
||||
const code = String(errorCode || '').toLowerCase();
|
||||
const known = {
|
||||
'0x87d1041c': 'Intune did not detect the app after installation. Check detection rules, install command, and app install timing.',
|
||||
'0x87d13b9f': 'App installation failed. Review Intune Management Extension logs and PSADT logs.',
|
||||
'1602': 'User deferred or cancelled installation. Confirm return code is mapped as retry and DeferRunInterval is configured when needed.',
|
||||
'3010': 'Soft reboot required. Confirm Intune return code behavior.',
|
||||
'1703': 'Legacy soft reboot mapping. Confirm this is intentional for the package.'
|
||||
};
|
||||
return known[code] || detail || '';
|
||||
}
|
||||
|
||||
function summarizeRows(rows, overview = {}) {
|
||||
const counts = { total: rows.length, installed: 0, failed: 0, pending: 0, notInstalled: 0, notApplicable: 0, unknown: 0 };
|
||||
for (const row of rows) {
|
||||
const state = row.installState || 'unknown';
|
||||
if (counts[state] == null) counts.unknown += 1;
|
||||
else counts[state] += 1;
|
||||
}
|
||||
return { counts, overview };
|
||||
}
|
||||
76
server/services/installerIntelService.js
Normal file
76
server/services/installerIntelService.js
Normal file
@@ -0,0 +1,76 @@
|
||||
import { INSTALLER_TECHNOLOGIES } from '../data/installerCatalog.js';
|
||||
|
||||
// Installer intelligence: infer the installer technology from a file name and
|
||||
// return recommended silent install/uninstall commands plus a detection
|
||||
// suggestion. Pure + table-driven, so it is fully unit-tested. Binary signature
|
||||
// sniffing (reading the file) is Phase 5; this works from the name/extension.
|
||||
|
||||
function extensionOf(fileName = '') {
|
||||
const match = String(fileName).toLowerCase().match(/(\.[a-z0-9]+)$/);
|
||||
return match ? match[1] : '';
|
||||
}
|
||||
|
||||
function render(template = '', { file = '', productCode = '' } = {}) {
|
||||
return String(template)
|
||||
.split('{file}').join(file)
|
||||
.split('{productCode}').join(productCode || '{productCode}');
|
||||
}
|
||||
|
||||
function shape(tech, ctx) {
|
||||
return {
|
||||
id: tech.id,
|
||||
label: tech.label,
|
||||
install: render(tech.install, ctx),
|
||||
uninstall: render(tech.uninstall, ctx),
|
||||
psadtInstall: render(tech.psadtInstall, ctx),
|
||||
psadtUninstall: tech.psadtUninstall ? render(tech.psadtUninstall, ctx) : '',
|
||||
detection: tech.detection,
|
||||
notes: tech.notes
|
||||
};
|
||||
}
|
||||
|
||||
export function listInstallerTechnologies() {
|
||||
return INSTALLER_TECHNOLOGIES.map(({ id, label, extensions, detection, notes }) => ({ id, label, extensions, detection, notes }));
|
||||
}
|
||||
|
||||
// Analyze a file name. Returns the best-guess technology, a confidence level,
|
||||
// and the full set of candidate technologies for that extension so the operator
|
||||
// can pick when the name alone is ambiguous (common for .exe).
|
||||
export function analyzeInstaller({ fileName = '', productCode = '' } = {}) {
|
||||
const ext = extensionOf(fileName);
|
||||
const lower = String(fileName).toLowerCase();
|
||||
const ctx = { file: fileName, productCode };
|
||||
|
||||
const byExt = INSTALLER_TECHNOLOGIES.filter((tech) => tech.extensions.includes(ext));
|
||||
if (!byExt.length) {
|
||||
return {
|
||||
fileName,
|
||||
extension: ext,
|
||||
confidence: 'unknown',
|
||||
primary: null,
|
||||
candidates: [],
|
||||
message: ext ? `No installer technology is known for "${ext}".` : 'Provide an installer file name with an extension.'
|
||||
};
|
||||
}
|
||||
|
||||
// Unique extension (.msi/.msp/.msix...) → high confidence, single technology.
|
||||
if (byExt.length === 1) {
|
||||
return { fileName, extension: ext, confidence: 'high', primary: shape(byExt[0], ctx), candidates: [shape(byExt[0], ctx)] };
|
||||
}
|
||||
|
||||
// Ambiguous (.exe): try a name hint, otherwise fall back to generic EXE.
|
||||
const hinted = byExt.find((tech) => (tech.hints || []).some((hint) => lower.includes(hint)) && tech.id !== 'exe');
|
||||
const generic = byExt.find((tech) => tech.id === 'exe') || byExt[0];
|
||||
const primary = hinted || generic;
|
||||
return {
|
||||
fileName,
|
||||
extension: ext,
|
||||
confidence: hinted ? 'medium' : 'low',
|
||||
primary: shape(primary, ctx),
|
||||
// Offer all candidates (generic last) so the UI can present a picker.
|
||||
candidates: [...byExt.filter((t) => t.id !== 'exe'), ...byExt.filter((t) => t.id === 'exe')].map((t) => shape(t, ctx)),
|
||||
message: hinted
|
||||
? `Matched "${hinted.label}" from the file name.`
|
||||
: 'Ambiguous EXE — pick the matching technology, or confirm the silent switch with the vendor.'
|
||||
};
|
||||
}
|
||||
66
server/services/intuneDriftService.js
Normal file
66
server/services/intuneDriftService.js
Normal file
@@ -0,0 +1,66 @@
|
||||
// Pure drift computation: compares the POSHManager plan (expected) against what
|
||||
// Microsoft Graph currently reports (live), for the win32LobApp metadata, its
|
||||
// assignments, and its supersedence/dependency relationships. No I/O — the
|
||||
// controller fetches live state and feeds it here, so this is fully unit-tested.
|
||||
|
||||
// Fields on the win32LobApp we treat as authoritative from the plan.
|
||||
const APP_DRIFT_FIELDS = [
|
||||
'displayName',
|
||||
'publisher',
|
||||
'installCommandLine',
|
||||
'uninstallCommandLine',
|
||||
'applicableArchitectures'
|
||||
];
|
||||
|
||||
function fieldDrift(expected = {}, actual = {}, fields = APP_DRIFT_FIELDS) {
|
||||
const drift = [];
|
||||
for (const field of fields) {
|
||||
const want = expected[field] ?? '';
|
||||
const got = actual[field] ?? '';
|
||||
if (String(want) !== String(got)) drift.push({ field, expected: want, actual: got });
|
||||
}
|
||||
return drift;
|
||||
}
|
||||
|
||||
function assignmentKey(assignment = {}) {
|
||||
const target = assignment.target || {};
|
||||
const type = (target['@odata.type'] || '').replace('#microsoft.graph.', '');
|
||||
return `${assignment.intent || ''}|${type}|${target.groupId || ''}`;
|
||||
}
|
||||
|
||||
function relationshipKey(rel = {}) {
|
||||
const type = (rel['@odata.type'] || '').replace('#microsoft.graph.', '');
|
||||
return `${type}|${rel.targetId || ''}`;
|
||||
}
|
||||
|
||||
// Generic set diff by key: returns items present only in expected (missing in
|
||||
// tenant) and only in actual (extra in tenant).
|
||||
function setDiff(expected, actual, keyFn) {
|
||||
const expectedKeys = new Map(expected.map((item) => [keyFn(item), item]));
|
||||
const actualKeys = new Map(actual.map((item) => [keyFn(item), item]));
|
||||
const missing = [...expectedKeys.keys()].filter((k) => !actualKeys.has(k));
|
||||
const extra = [...actualKeys.keys()].filter((k) => !expectedKeys.has(k));
|
||||
return { missing, extra };
|
||||
}
|
||||
|
||||
export function computeDrift({
|
||||
expectedApp = {},
|
||||
liveApp = {},
|
||||
expectedAssignments = [],
|
||||
liveAssignments = [],
|
||||
expectedRelationships = [],
|
||||
liveRelationships = []
|
||||
} = {}) {
|
||||
const appDrift = fieldDrift(expectedApp, liveApp);
|
||||
const assignmentDrift = setDiff(expectedAssignments, liveAssignments, assignmentKey);
|
||||
const relationshipDrift = setDiff(expectedRelationships, liveRelationships, relationshipKey);
|
||||
|
||||
const inSync =
|
||||
appDrift.length === 0 &&
|
||||
assignmentDrift.missing.length === 0 &&
|
||||
assignmentDrift.extra.length === 0 &&
|
||||
relationshipDrift.missing.length === 0 &&
|
||||
relationshipDrift.extra.length === 0;
|
||||
|
||||
return { inSync, appDrift, assignmentDrift, relationshipDrift };
|
||||
}
|
||||
69
server/services/intunePromotionService.js
Normal file
69
server/services/intunePromotionService.js
Normal file
@@ -0,0 +1,69 @@
|
||||
// Pure helpers for the application promotion pipeline: producing the next
|
||||
// version's deployment plan and advancing assignment rings. No I/O, so both are
|
||||
// unit-tested; the controller persists/pushes the results.
|
||||
|
||||
// Bump the trailing numeric component of a version string (1.2.3 -> 1.2.4,
|
||||
// 126 -> 127). Falls back to appending ".1" when no number is present.
|
||||
export function bumpVersion(version = '') {
|
||||
const text = String(version).trim();
|
||||
if (!text) return '1.0.0';
|
||||
const match = text.match(/^(.*?)(\d+)(\D*)$/);
|
||||
if (!match) return `${text}.1`;
|
||||
const [, head, num, tail] = match;
|
||||
return `${head}${Number(num) + 1}${tail}`;
|
||||
}
|
||||
|
||||
// Build a fresh deployment payload for the next version, based on an existing
|
||||
// plan. The new plan is unpublished (no graphAppId), draft status, and keeps the
|
||||
// application link so the catalog groups both versions.
|
||||
export function cloneForNextVersion(deployment, { version, applicationId } = {}) {
|
||||
if (!deployment) throw new Error('A source deployment is required');
|
||||
const nextVersion = version || bumpVersion(extractVersion(deployment.name));
|
||||
const baseName = deployment.name.replace(/\s+\d[\d.]*$/, '').trim() || deployment.name;
|
||||
return {
|
||||
name: `${baseName} ${nextVersion}`.trim(),
|
||||
profileId: deployment.profileId || null,
|
||||
scriptId: deployment.scriptId || null,
|
||||
applicationId: applicationId || deployment.applicationId || null,
|
||||
sourceFolder: deployment.sourceFolder || '',
|
||||
intunewinFile: '',
|
||||
commandStyle: deployment.commandStyle,
|
||||
installBehavior: deployment.installBehavior,
|
||||
restartBehavior: deployment.restartBehavior,
|
||||
uiMode: deployment.uiMode,
|
||||
requirements: { ...(deployment.requirements || {}) },
|
||||
installCommand: deployment.installCommand,
|
||||
uninstallCommand: deployment.uninstallCommand,
|
||||
detectionType: deployment.detectionType,
|
||||
detectionRule: deployment.detectionRule,
|
||||
returnCodes: deployment.returnCodes || [],
|
||||
assignments: deployment.assignments || [],
|
||||
relationships: deployment.relationships || [],
|
||||
status: 'draft',
|
||||
graphAppId: '',
|
||||
graphConnectionId: deployment.graphConnectionId || null,
|
||||
notes: `Version ${nextVersion} created from ${deployment.name}.`,
|
||||
visibility: deployment.visibility,
|
||||
groupId: deployment.groupId || null
|
||||
};
|
||||
}
|
||||
|
||||
function extractVersion(name = '') {
|
||||
const match = String(name).match(/(\d[\d.]*)\s*$/);
|
||||
return match ? match[1] : '';
|
||||
}
|
||||
|
||||
// Advance a named assignment ring to a new intent (e.g. promote "Broad" from
|
||||
// available to required). Returns a new assignments array; unknown rings are a
|
||||
// no-op so callers can detect "nothing changed".
|
||||
export function setRingIntent(assignments = [], ring, intent) {
|
||||
let changed = false;
|
||||
const next = assignments.map((assignment) => {
|
||||
if (assignment.ring?.toLowerCase() === String(ring).toLowerCase() && assignment.intent !== intent) {
|
||||
changed = true;
|
||||
return { ...assignment, intent };
|
||||
}
|
||||
return assignment;
|
||||
});
|
||||
return { assignments: next, changed };
|
||||
}
|
||||
268
server/services/intunePublishService.js
Normal file
268
server/services/intunePublishService.js
Normal file
@@ -0,0 +1,268 @@
|
||||
// Pure mapping from a POSHManager Intune deployment plan (+ optional PSADT
|
||||
// profile) to the Microsoft Graph `win32LobApp` resource shape and its
|
||||
// assignment/relationship bodies.
|
||||
//
|
||||
// These functions perform no I/O so they can be unit-tested without a tenant.
|
||||
// The live HTTP calls live in graphService; the publish controller composes the
|
||||
// two. Field shapes follow the Graph beta `win32LobApp` documentation.
|
||||
|
||||
const RESTART_BEHAVIOR = {
|
||||
'return-code': 'basedOnReturnCode',
|
||||
suppress: 'suppress',
|
||||
intune: 'force'
|
||||
};
|
||||
|
||||
const RETURN_CODE_TYPES = new Set(['success', 'softReboot', 'hardReboot', 'retry', 'failed']);
|
||||
|
||||
// Map a human OS string ("Windows 10 22H2", "Windows 11 23H2") to the Graph
|
||||
// windowsMinimumOperatingSystem boolean flag. Defaults to a safe broad floor.
|
||||
function minimumSupportedOperatingSystem(minOs = '') {
|
||||
const text = String(minOs).toLowerCase();
|
||||
const flags = {
|
||||
v10_1607: false, v10_1703: false, v10_1709: false, v10_1803: false,
|
||||
v10_1809: false, v10_1903: false, v10_1909: false, v10_2004: false,
|
||||
v10_20H2: false, v10_21H1: false, v10_21H2: false, v10_22H2: false,
|
||||
v11_21H2: false, v11_22H2: false, v11_23H2: false, v11_24H2: false
|
||||
};
|
||||
const map = [
|
||||
['11 24h2', 'v11_24H2'], ['11 23h2', 'v11_23H2'], ['11 22h2', 'v11_22H2'], ['11 21h2', 'v11_21H2'],
|
||||
['22h2', 'v10_22H2'], ['21h2', 'v10_21H2'], ['21h1', 'v10_21H1'], ['20h2', 'v10_20H2'],
|
||||
['2004', 'v10_2004'], ['1909', 'v10_1909'], ['1903', 'v10_1903'], ['1809', 'v10_1809'],
|
||||
['1803', 'v10_1803'], ['1709', 'v10_1709'], ['1703', 'v10_1703'], ['1607', 'v10_1607']
|
||||
];
|
||||
const match = map.find(([needle]) => text.includes(needle));
|
||||
flags[match ? match[1] : 'v10_1809'] = true;
|
||||
return flags;
|
||||
}
|
||||
|
||||
function applicableArchitectures(architecture = 'x64') {
|
||||
if (architecture === 'both') return 'x64,x86';
|
||||
if (architecture === 'x86') return 'x86';
|
||||
return 'x64';
|
||||
}
|
||||
|
||||
function mapReturnCodes(returnCodes = []) {
|
||||
const mapped = (returnCodes || []).map((entry) => ({
|
||||
returnCode: Number(entry.code),
|
||||
type: RETURN_CODE_TYPES.has(entry.type) ? entry.type : 'failed'
|
||||
})).filter((entry) => Number.isFinite(entry.returnCode));
|
||||
// Intune requires at least the canonical 0/1707/3010/1641 floor; ensure 0=success exists.
|
||||
if (!mapped.some((entry) => entry.returnCode === 0)) mapped.unshift({ returnCode: 0, type: 'success' });
|
||||
return mapped;
|
||||
}
|
||||
|
||||
// Detection rule mapping. Our model stores a single `detectionRule` string plus
|
||||
// a `detectionType`, so structured types (file/registry) that need more fields
|
||||
// are emitted best-effort and a warning is returned for operator review.
|
||||
function mapDetectionRules(deployment, warnings) {
|
||||
const rule = String(deployment.detectionRule || '').trim();
|
||||
switch (deployment.detectionType) {
|
||||
case 'msi-product-code':
|
||||
if (!rule) warnings.push('MSI detection selected but no product code provided.');
|
||||
return [{
|
||||
'@odata.type': '#microsoft.graph.win32LobAppProductCodeDetection',
|
||||
productCode: rule,
|
||||
productVersionOperator: 'notConfigured',
|
||||
productVersion: null
|
||||
}];
|
||||
case 'custom-script':
|
||||
if (!rule) warnings.push('Custom-script detection selected but the script body is empty.');
|
||||
return [{
|
||||
'@odata.type': '#microsoft.graph.win32LobAppPowerShellScriptDetection',
|
||||
scriptContent: Buffer.from(rule, 'utf8').toString('base64'),
|
||||
enforceSignatureCheck: false,
|
||||
runAs32Bit: false
|
||||
}];
|
||||
case 'file-version':
|
||||
warnings.push('File-version detection requires structured path/file/version fields; emitted a best-effort rule for review.');
|
||||
return [{
|
||||
'@odata.type': '#microsoft.graph.win32LobAppFileSystemDetection',
|
||||
path: rule || 'C:\\Program Files',
|
||||
fileOrFolderName: '',
|
||||
check32BitOn64System: false,
|
||||
detectionType: 'exists',
|
||||
operator: 'notConfigured',
|
||||
detectionValue: null
|
||||
}];
|
||||
case 'registry':
|
||||
warnings.push('Registry detection requires structured key/value fields; emitted a best-effort rule for review.');
|
||||
return [{
|
||||
'@odata.type': '#microsoft.graph.win32LobAppRegistryDetection',
|
||||
keyPath: rule || 'HKEY_LOCAL_MACHINE\\SOFTWARE',
|
||||
valueName: '',
|
||||
check32BitOn64System: false,
|
||||
detectionType: 'exists',
|
||||
operator: 'notConfigured',
|
||||
detectionValue: null
|
||||
}];
|
||||
default:
|
||||
warnings.push(`Unknown detection type "${deployment.detectionType}"; defaulted to script detection.`);
|
||||
return [{
|
||||
'@odata.type': '#microsoft.graph.win32LobAppPowerShellScriptDetection',
|
||||
scriptContent: Buffer.from(rule, 'utf8').toString('base64'),
|
||||
enforceSignatureCheck: false,
|
||||
runAs32Bit: false
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
export function buildWin32LobAppPayload(deployment, profile = null) {
|
||||
const warnings = [];
|
||||
if (!deployment) throw new Error('A deployment is required to build a win32LobApp payload');
|
||||
const requirements = deployment.requirements || {};
|
||||
const displayName = profile?.appName
|
||||
? `${profile.appVendor ? `${profile.appVendor} ` : ''}${profile.appName}${profile.appVersion ? ` ${profile.appVersion}` : ''}`.trim()
|
||||
: deployment.name;
|
||||
const fileName = deployment.intunewinFile || `${(deployment.name || 'package').replace(/[^A-Za-z0-9._-]+/g, '-')}.intunewin`;
|
||||
|
||||
if (!deployment.installCommand) warnings.push('Install command is empty.');
|
||||
if (!deployment.uninstallCommand) warnings.push('Uninstall command is empty.');
|
||||
if (!deployment.intunewinFile) warnings.push('No .intunewin file recorded; content must be uploaded before the app is installable (Phase 2).');
|
||||
|
||||
const payload = {
|
||||
'@odata.type': '#microsoft.graph.win32LobApp',
|
||||
displayName,
|
||||
description: deployment.notes || displayName,
|
||||
publisher: profile?.appVendor || 'POSHManager',
|
||||
fileName,
|
||||
setupFilePath: fileName,
|
||||
installCommandLine: deployment.installCommand || '',
|
||||
uninstallCommandLine: deployment.uninstallCommand || '',
|
||||
applicableArchitectures: applicableArchitectures(requirements.architecture),
|
||||
allowAvailableUninstall: true,
|
||||
minimumFreeDiskSpaceInMB: Number(requirements.diskSpaceMb) > 0 ? Number(requirements.diskSpaceMb) : null,
|
||||
minimumSupportedOperatingSystem: minimumSupportedOperatingSystem(requirements.minOs),
|
||||
installExperience: {
|
||||
'@odata.type': 'microsoft.graph.win32LobAppInstallExperience',
|
||||
runAsAccount: deployment.installBehavior === 'user' ? 'user' : 'system',
|
||||
deviceRestartBehavior: RESTART_BEHAVIOR[deployment.restartBehavior] || 'basedOnReturnCode'
|
||||
},
|
||||
returnCodes: mapReturnCodes(deployment.returnCodes),
|
||||
detectionRules: mapDetectionRules(deployment, warnings),
|
||||
requirementRules: [],
|
||||
msiInformation: null,
|
||||
runAs32Bit: Boolean(requirements.runAs32Bit)
|
||||
};
|
||||
|
||||
return { payload, warnings };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 3 — assignments / filters / rings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ASSIGNMENT_INTENTS = new Set(['available', 'required', 'uninstall']);
|
||||
|
||||
function assignmentTarget(assignment, warnings) {
|
||||
const filter = assignment.filterId
|
||||
? {
|
||||
deviceAndAppManagementAssignmentFilterId: assignment.filterId,
|
||||
deviceAndAppManagementAssignmentFilterType: assignment.filterType && assignment.filterType !== 'none' ? assignment.filterType : 'include'
|
||||
}
|
||||
: { deviceAndAppManagementAssignmentFilterId: null, deviceAndAppManagementAssignmentFilterType: 'none' };
|
||||
|
||||
if (assignment.groupMode === 'allDevices') {
|
||||
return { '@odata.type': '#microsoft.graph.allDevicesAssignmentTarget', ...filter };
|
||||
}
|
||||
if (assignment.groupMode === 'allUsers') {
|
||||
return { '@odata.type': '#microsoft.graph.allLicensedUsersAssignmentTarget', ...filter };
|
||||
}
|
||||
if (!assignment.groupId) {
|
||||
warnings.push(`Assignment "${assignment.ring}" has no Entra group id and was skipped. Resolve the group to its object id first.`);
|
||||
return null;
|
||||
}
|
||||
const isExclusion = assignment.intent === 'exclude';
|
||||
return {
|
||||
'@odata.type': isExclusion
|
||||
? '#microsoft.graph.exclusionGroupAssignmentTarget'
|
||||
: '#microsoft.graph.groupAssignmentTarget',
|
||||
groupId: assignment.groupId,
|
||||
...filter
|
||||
};
|
||||
}
|
||||
|
||||
// Build the `mobileAppAssignments` array for the /assign action. Exclusions map
|
||||
// to exclusionGroupAssignmentTarget (Graph still requires a real intent, so we
|
||||
// exclude from "required"). Group assignments without a resolved object id are
|
||||
// skipped with a warning rather than producing an invalid request.
|
||||
export function buildAssignmentsPayload(deployment) {
|
||||
const warnings = [];
|
||||
const assignments = [];
|
||||
for (const assignment of deployment.assignments || []) {
|
||||
const target = assignmentTarget(assignment, warnings);
|
||||
if (!target) continue;
|
||||
const intent = assignment.intent === 'exclude'
|
||||
? 'required'
|
||||
: (ASSIGNMENT_INTENTS.has(assignment.intent) ? assignment.intent : 'required');
|
||||
assignments.push({
|
||||
'@odata.type': '#microsoft.graph.mobileAppAssignment',
|
||||
intent,
|
||||
target,
|
||||
settings: {
|
||||
'@odata.type': '#microsoft.graph.win32LobAppAssignmentSettings',
|
||||
notifications: 'showAll',
|
||||
restartSettings: null,
|
||||
installTimeSettings: null,
|
||||
deliveryOptimizationPriority: 'notConfigured',
|
||||
autoUpdateSettings: assignment.autoUpdate
|
||||
? { '@odata.type': '#microsoft.graph.win32LobAppAutoUpdateSettings', autoUpdateSupersededApps: 'enabled' }
|
||||
: null
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!assignments.length) warnings.push('No assignable targets were produced; nothing would be assigned.');
|
||||
return { assignments, warnings };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 4 — supersedence & dependency relationships
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Intune limits a supersedence graph to 11 nodes total (this app + superseded +
|
||||
// their related apps). We can only see this app's direct edges, so we enforce a
|
||||
// local ceiling of 10 supersedence targets and flag that the full graph limit is
|
||||
// tenant-wide. Self-references and duplicates are rejected as cycles.
|
||||
export const MAX_SUPERSEDENCE_TARGETS = 10;
|
||||
|
||||
export function validateRelationships(relationships = [], selfAppId = '') {
|
||||
const errors = [];
|
||||
const seen = new Set();
|
||||
let supersedenceCount = 0;
|
||||
for (const rel of relationships) {
|
||||
const target = rel.targetAppId;
|
||||
if (!target) { errors.push('A relationship is missing targetAppId.'); continue; }
|
||||
if (selfAppId && target === selfAppId) errors.push(`An app cannot have a ${rel.kind} relationship with itself (${target}).`);
|
||||
const key = `${rel.kind}:${target}`;
|
||||
if (seen.has(key)) errors.push(`Duplicate ${rel.kind} relationship to ${target}.`);
|
||||
seen.add(key);
|
||||
if (rel.kind === 'supersedence') supersedenceCount += 1;
|
||||
}
|
||||
if (supersedenceCount > MAX_SUPERSEDENCE_TARGETS) {
|
||||
errors.push(`Too many supersedence targets (${supersedenceCount}); Intune allows at most ${MAX_SUPERSEDENCE_TARGETS} direct targets (11-node graph limit).`);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function buildRelationshipsPayload(relationships = [], selfAppId = '') {
|
||||
const errors = validateRelationships(relationships, selfAppId);
|
||||
if (errors.length) {
|
||||
const error = new Error(errors.join(' '));
|
||||
error.validation = errors;
|
||||
throw error;
|
||||
}
|
||||
const mapped = relationships.map((rel) => {
|
||||
if (rel.kind === 'dependency') {
|
||||
return {
|
||||
'@odata.type': '#microsoft.graph.mobileAppDependency',
|
||||
targetId: rel.targetAppId,
|
||||
dependencyType: rel.mode === 'autoInstall' ? 'autoInstall' : 'detect'
|
||||
};
|
||||
}
|
||||
return {
|
||||
'@odata.type': '#microsoft.graph.mobileAppSupersedence',
|
||||
targetId: rel.targetAppId,
|
||||
supersedenceType: rel.mode === 'replace' ? 'replace' : 'update'
|
||||
};
|
||||
});
|
||||
return { relationships: mapped };
|
||||
}
|
||||
80
server/services/intuneWinBuilder.js
Normal file
80
server/services/intuneWinBuilder.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { config } from '../config.js';
|
||||
|
||||
// Wraps a PSADT/package source folder into a `.intunewin` using the Microsoft
|
||||
// Win32 Content Prep Tool (IntuneWinAppUtil.exe) on Windows, or an optional
|
||||
// cross-platform packager via INTUNEWIN_BUILD_COMMAND. The command construction
|
||||
// is pure and unit-tested; the spawn wrapper is thin.
|
||||
|
||||
export function resolveToolPath() {
|
||||
return config.intunewinUtilPath || path.join(config.toolsDir, 'IntuneWinAppUtil.exe');
|
||||
}
|
||||
|
||||
// Where to put the tool and whether we can build here.
|
||||
export function builderAvailability() {
|
||||
if (config.intunewinBuildCommand) {
|
||||
return { available: true, mode: 'external-command', toolPath: null };
|
||||
}
|
||||
const toolPath = resolveToolPath();
|
||||
if (!fs.existsSync(toolPath)) {
|
||||
return { available: false, mode: 'bundled', toolPath, reason: `IntuneWinAppUtil.exe not found at ${toolPath}. Place it in the tools directory or set INTUNEWIN_UTIL_PATH / INTUNEWIN_BUILD_COMMAND.` };
|
||||
}
|
||||
if (process.platform !== 'win32') {
|
||||
return { available: false, mode: 'bundled', toolPath, reason: 'IntuneWinAppUtil.exe requires Windows. Set INTUNEWIN_BUILD_COMMAND to use a cross-platform packager.' };
|
||||
}
|
||||
return { available: true, mode: 'bundled', toolPath };
|
||||
}
|
||||
|
||||
// Pure: produce the spawn descriptor for the configured packager.
|
||||
export function buildPackagerInvocation({ toolPath, sourceFolder, setupFile, outputDir, commandTemplate }) {
|
||||
if (commandTemplate) {
|
||||
const command = commandTemplate
|
||||
.split('{source}').join(sourceFolder)
|
||||
.split('{setup}').join(setupFile)
|
||||
.split('{output}').join(outputDir);
|
||||
return { shell: true, command };
|
||||
}
|
||||
return {
|
||||
shell: false,
|
||||
command: toolPath,
|
||||
args: ['-c', sourceFolder, '-s', setupFile, '-o', outputDir, '-q']
|
||||
};
|
||||
}
|
||||
|
||||
// IntuneWinAppUtil names the output after the setup file's base name.
|
||||
export function expectedOutputFile(outputDir, setupFile) {
|
||||
return path.join(outputDir, `${path.parse(setupFile).name}.intunewin`);
|
||||
}
|
||||
|
||||
export function buildIntuneWin({ sourceFolder, setupFile, outputDir }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const availability = builderAvailability();
|
||||
if (!availability.available) return reject(new Error(availability.reason || 'Packager not available'));
|
||||
if (!sourceFolder || !fs.existsSync(sourceFolder)) return reject(new Error(`Source folder not found: ${sourceFolder}`));
|
||||
if (!setupFile) return reject(new Error('A setup file (entry installer) is required'));
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const invocation = buildPackagerInvocation({
|
||||
toolPath: availability.toolPath,
|
||||
sourceFolder,
|
||||
setupFile,
|
||||
outputDir,
|
||||
commandTemplate: config.intunewinBuildCommand
|
||||
});
|
||||
|
||||
const child = invocation.shell
|
||||
? spawn(invocation.command, { shell: true })
|
||||
: spawn(invocation.command, invocation.args, { shell: false });
|
||||
|
||||
let stderr = '';
|
||||
child.stderr.on('data', (chunk) => { stderr += String(chunk); });
|
||||
child.on('error', (error) => reject(new Error(`Packager failed to start: ${error.message}`)));
|
||||
child.on('close', (code) => {
|
||||
const outputFile = expectedOutputFile(outputDir, setupFile);
|
||||
if (code === 0 && fs.existsSync(outputFile)) return resolve({ outputFile });
|
||||
reject(new Error(`Packager exited with code ${code}${stderr ? `: ${stderr.trim()}` : ''}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
104
server/services/intunewinParser.js
Normal file
104
server/services/intunewinParser.js
Normal file
@@ -0,0 +1,104 @@
|
||||
import zlib from 'node:zlib';
|
||||
|
||||
// Minimal reader for the `.intunewin` package produced by the Microsoft Win32
|
||||
// Content Prep Tool. The package is a ZIP containing:
|
||||
// IntuneWinPackage/Metadata/Detection.xml (encryption info + sizes)
|
||||
// IntuneWinPackage/Contents/IntunePackage.intunewin (AES-encrypted payload)
|
||||
//
|
||||
// We avoid a third-party zip dependency by parsing the central directory and
|
||||
// inflating entries with the built-in zlib. Only stored (0) and deflate (8)
|
||||
// compression methods are supported, which is what the tool emits.
|
||||
|
||||
const EOCD_SIG = 0x06054b50;
|
||||
const CEN_SIG = 0x02014b50;
|
||||
const LOC_SIG = 0x04034b50;
|
||||
|
||||
function findEndOfCentralDirectory(buffer) {
|
||||
// EOCD is at the end; scan backwards past any (absent) comment.
|
||||
for (let i = buffer.length - 22; i >= 0; i--) {
|
||||
if (buffer.readUInt32LE(i) === EOCD_SIG) return i;
|
||||
}
|
||||
throw new Error('Not a valid .intunewin (ZIP end-of-central-directory not found)');
|
||||
}
|
||||
|
||||
function readCentralDirectory(buffer) {
|
||||
const eocd = findEndOfCentralDirectory(buffer);
|
||||
const count = buffer.readUInt16LE(eocd + 10);
|
||||
let offset = buffer.readUInt32LE(eocd + 16);
|
||||
const entries = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (buffer.readUInt32LE(offset) !== CEN_SIG) break;
|
||||
const method = buffer.readUInt16LE(offset + 10);
|
||||
const compressedSize = buffer.readUInt32LE(offset + 20);
|
||||
const nameLen = buffer.readUInt16LE(offset + 28);
|
||||
const extraLen = buffer.readUInt16LE(offset + 30);
|
||||
const commentLen = buffer.readUInt16LE(offset + 32);
|
||||
const localHeaderOffset = buffer.readUInt32LE(offset + 42);
|
||||
const name = buffer.toString('utf8', offset + 46, offset + 46 + nameLen);
|
||||
entries.push({ name, method, compressedSize, localHeaderOffset });
|
||||
offset += 46 + nameLen + extraLen + commentLen;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function readEntryData(buffer, entry) {
|
||||
const o = entry.localHeaderOffset;
|
||||
if (buffer.readUInt32LE(o) !== LOC_SIG) throw new Error(`Bad local header for ${entry.name}`);
|
||||
const nameLen = buffer.readUInt16LE(o + 26);
|
||||
const extraLen = buffer.readUInt16LE(o + 28);
|
||||
const dataStart = o + 30 + nameLen + extraLen;
|
||||
const raw = buffer.subarray(dataStart, dataStart + entry.compressedSize);
|
||||
if (entry.method === 0) return Buffer.from(raw);
|
||||
if (entry.method === 8) return zlib.inflateRawSync(raw);
|
||||
throw new Error(`Unsupported ZIP compression method ${entry.method} for ${entry.name}`);
|
||||
}
|
||||
|
||||
function findEntry(entries, suffix) {
|
||||
// Match by path suffix so we tolerate the IntuneWinPackage/ prefix casing.
|
||||
const lower = suffix.toLowerCase();
|
||||
return entries.find((e) => e.name.toLowerCase().replace(/\\/g, '/').endsWith(lower));
|
||||
}
|
||||
|
||||
function xmlValue(xml, tag) {
|
||||
const match = xml.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'i'));
|
||||
return match ? match[1].trim() : '';
|
||||
}
|
||||
|
||||
// Graph's commit step expects camelCase fileEncryptionInfo; map directly from
|
||||
// the Detection.xml EncryptionInfo element.
|
||||
function parseEncryptionInfo(xml) {
|
||||
const block = xml.match(/<EncryptionInfo>([\s\S]*?)<\/EncryptionInfo>/i);
|
||||
if (!block) throw new Error('Detection.xml is missing EncryptionInfo');
|
||||
const info = block[1];
|
||||
return {
|
||||
encryptionKey: xmlValue(info, 'EncryptionKey'),
|
||||
macKey: xmlValue(info, 'MacKey'),
|
||||
initializationVector: xmlValue(info, 'InitializationVector'),
|
||||
mac: xmlValue(info, 'Mac'),
|
||||
profileIdentifier: xmlValue(info, 'ProfileIdentifier') || 'ProfileVersion1',
|
||||
fileDigest: xmlValue(info, 'FileDigest'),
|
||||
fileDigestAlgorithm: xmlValue(info, 'FileDigestAlgorithm') || 'SHA256'
|
||||
};
|
||||
}
|
||||
|
||||
export function parseIntunewin(buffer) {
|
||||
if (!Buffer.isBuffer(buffer)) buffer = Buffer.from(buffer);
|
||||
const entries = readCentralDirectory(buffer);
|
||||
const detectionEntry = findEntry(entries, 'metadata/detection.xml');
|
||||
if (!detectionEntry) throw new Error('Detection.xml not found in .intunewin package');
|
||||
const contentEntry = findEntry(entries, 'contents/intunepackage.intunewin');
|
||||
if (!contentEntry) throw new Error('Encrypted content (IntunePackage.intunewin) not found in package');
|
||||
|
||||
const xml = readEntryData(buffer, detectionEntry).toString('utf8');
|
||||
const encryptedContent = readEntryData(buffer, contentEntry);
|
||||
const unencryptedContentSize = Number(xmlValue(xml, 'UnencryptedContentSize')) || 0;
|
||||
|
||||
return {
|
||||
setupFile: xmlValue(xml, 'SetupFile') || xmlValue(xml, 'FileName') || '',
|
||||
name: xmlValue(xml, 'Name') || '',
|
||||
unencryptedContentSize,
|
||||
encryptedContent,
|
||||
encryptedContentSize: encryptedContent.length,
|
||||
fileEncryptionInfo: parseEncryptionInfo(xml)
|
||||
};
|
||||
}
|
||||
44
server/services/logger.js
Normal file
44
server/services/logger.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import winston from 'winston';
|
||||
import { config } from '../config.js';
|
||||
|
||||
fs.mkdirSync(config.logDir, { recursive: true });
|
||||
|
||||
const logFormat = winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.json()
|
||||
);
|
||||
|
||||
export const logger = winston.createLogger({
|
||||
level: config.isProduction ? 'info' : 'debug',
|
||||
format: logFormat,
|
||||
transports: [
|
||||
new winston.transports.File({ filename: path.join(config.logDir, 'system.log'), maxsize: 5_000_000, maxFiles: 5 }),
|
||||
...(!config.isProduction
|
||||
? [new winston.transports.Console({
|
||||
format: winston.format.combine(winston.format.colorize(), winston.format.simple())
|
||||
})]
|
||||
: [])
|
||||
]
|
||||
});
|
||||
|
||||
export function readSystemLog(limit = 300) {
|
||||
// System logs are line-delimited JSON; malformed historic lines are returned as plain messages.
|
||||
const file = path.join(config.logDir, 'system.log');
|
||||
if (!fs.existsSync(file)) return [];
|
||||
const content = fs.readFileSync(file, 'utf8');
|
||||
return content
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.slice(-limit)
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch {
|
||||
return { level: 'info', message: line };
|
||||
}
|
||||
});
|
||||
}
|
||||
317
server/services/powershellRunner.js
Normal file
317
server/services/powershellRunner.js
Normal file
@@ -0,0 +1,317 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { db, id, now } from '../db.js';
|
||||
import { decryptSecret } from './cryptoStore.js';
|
||||
import { config } from '../config.js';
|
||||
import { logger } from './logger.js';
|
||||
import { listExecutableAssets } from '../models/assetModel.js';
|
||||
import { listExecutableCustomVariables } from '../models/variableModel.js';
|
||||
import { getBooleanSetting } from '../models/settingsModel.js';
|
||||
|
||||
// The execution kill-switch can be set at boot via ALLOW_SCRIPT_EXECUTION and
|
||||
// flipped at runtime via the Config screen (allow_script_execution). The DB
|
||||
// value, when present, takes precedence so the UI toggle is actually effective.
|
||||
function scriptExecutionAllowed() {
|
||||
return getBooleanSetting('allow_script_execution', config.allowScriptExecution);
|
||||
}
|
||||
|
||||
export function executeRunPlan(runplanId, userId) {
|
||||
// Create the durable job record before async execution so the UI can attach to logs immediately.
|
||||
const runplan = db.prepare('SELECT * FROM runplans WHERE id = ?').get(runplanId);
|
||||
if (!runplan) throw new Error('RunPlan not found');
|
||||
const script = db.prepare('SELECT * FROM scripts WHERE id = ?').get(runplan.script_id);
|
||||
if (!script) throw new Error('Script not found');
|
||||
const hosts = db.prepare(`
|
||||
SELECT h.*, c.name AS credential_name, c.kind AS credential_kind, c.username AS credential_username,
|
||||
c.secret_cipher, c.secret_iv, c.secret_tag
|
||||
FROM runplan_hosts rh
|
||||
JOIN hosts h ON h.id = rh.host_id
|
||||
LEFT JOIN credentials c ON c.id = h.credential_id
|
||||
WHERE rh.runplan_id = ?
|
||||
ORDER BY h.name
|
||||
`).all(runplanId);
|
||||
if (hosts.length === 0) throw new Error('RunPlan has no hosts');
|
||||
|
||||
const jobId = id('job');
|
||||
db.prepare(`
|
||||
INSERT INTO jobs (id, runplan_id, script_id, status, triggered_by, started_at, created_at)
|
||||
VALUES (?, ?, ?, 'running', ?, ?, ?)
|
||||
`).run(jobId, runplan.id, script.id, userId, now(), now());
|
||||
|
||||
for (const host of hosts) {
|
||||
db.prepare(`
|
||||
INSERT INTO job_hosts (id, job_id, host_id, status, started_at)
|
||||
VALUES (?, ?, ?, 'queued', NULL)
|
||||
`).run(id('jhost'), jobId, host.id);
|
||||
}
|
||||
|
||||
const executionContext = {
|
||||
jobId,
|
||||
runplanId: runplan.id,
|
||||
scriptId: script.id,
|
||||
scriptName: script.name,
|
||||
triggeredBy: userId,
|
||||
customVariables: listExecutableCustomVariables(userId),
|
||||
assets: listExecutableAssets(userId)
|
||||
};
|
||||
|
||||
void runJob(jobId, script, hosts, Boolean(runplan.parallel), executionContext).catch((error) => {
|
||||
logger.error('job runner crashed', { jobId, error });
|
||||
db.prepare('UPDATE jobs SET status = ?, ended_at = ? WHERE id = ?').run('failed', now(), jobId);
|
||||
});
|
||||
|
||||
return db.prepare('SELECT * FROM jobs WHERE id = ?').get(jobId);
|
||||
}
|
||||
|
||||
export function executeScriptTest(scriptId, payload, userId) {
|
||||
const script = db.prepare(`
|
||||
SELECT * FROM scripts
|
||||
WHERE id = ? AND (
|
||||
visibility = 'shared'
|
||||
OR owner_user_id = ?
|
||||
OR group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?)
|
||||
)
|
||||
`).get(scriptId, userId, userId);
|
||||
if (!script) throw new Error('Script not found');
|
||||
|
||||
const host = db.prepare(`
|
||||
SELECT h.*, c.name AS credential_name, c.kind AS credential_kind, c.username AS credential_username,
|
||||
c.secret_cipher, c.secret_iv, c.secret_tag
|
||||
FROM hosts h
|
||||
JOIN credentials c ON c.id = ?
|
||||
AND (
|
||||
c.visibility = 'shared'
|
||||
OR c.owner_user_id = ?
|
||||
OR c.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?)
|
||||
)
|
||||
WHERE h.id = ? AND (
|
||||
h.visibility = 'shared'
|
||||
OR h.owner_user_id = ?
|
||||
OR h.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?)
|
||||
)
|
||||
`).get(payload.credentialId, userId, userId, payload.hostId, userId, userId);
|
||||
if (!host) throw new Error('Visible host and credential are required');
|
||||
|
||||
const jobId = id('job');
|
||||
db.prepare(`
|
||||
INSERT INTO jobs (id, runplan_id, script_id, status, triggered_by, started_at, created_at)
|
||||
VALUES (?, NULL, ?, 'running', ?, ?, ?)
|
||||
`).run(jobId, script.id, userId, now(), now());
|
||||
db.prepare(`
|
||||
INSERT INTO job_hosts (id, job_id, host_id, status, started_at)
|
||||
VALUES (?, ?, ?, 'queued', NULL)
|
||||
`).run(id('jhost'), jobId, host.id);
|
||||
|
||||
const executionContext = {
|
||||
jobId,
|
||||
runplanId: 'script-test',
|
||||
scriptId: script.id,
|
||||
scriptName: script.name,
|
||||
triggeredBy: userId,
|
||||
testRun: true,
|
||||
customVariables: listExecutableCustomVariables(userId),
|
||||
assets: listExecutableAssets(userId)
|
||||
};
|
||||
|
||||
appendLog(jobId, host.id, 'system', `Test run requested for ${script.name} using credential ${host.credential_name}`);
|
||||
void runJob(jobId, script, [host], false, executionContext).catch((error) => {
|
||||
logger.error('script test runner crashed', { jobId, error });
|
||||
appendLog(jobId, host.id, 'stderr', `Script test runner crashed: ${error.message}`);
|
||||
db.prepare('UPDATE jobs SET status = ?, ended_at = ? WHERE id = ?').run('failed', now(), jobId);
|
||||
});
|
||||
|
||||
return db.prepare('SELECT * FROM jobs WHERE id = ?').get(jobId);
|
||||
}
|
||||
|
||||
async function runJob(jobId, script, hosts, parallel, executionContext) {
|
||||
// RunPlans can fan out in parallel or execute serially while preserving identical log semantics.
|
||||
const runner = async (host) => runHost(jobId, script, host, executionContext);
|
||||
if (parallel) {
|
||||
await Promise.all(hosts.map(runner));
|
||||
} else {
|
||||
for (const host of hosts) await runner(host);
|
||||
}
|
||||
|
||||
const failed = db.prepare('SELECT COUNT(*) AS count FROM job_hosts WHERE job_id = ? AND status = ?').get(jobId, 'failed').count;
|
||||
db.prepare('UPDATE jobs SET status = ?, ended_at = ? WHERE id = ?').run(failed ? 'failed' : 'completed', now(), jobId);
|
||||
}
|
||||
|
||||
async function runHost(jobId, script, host, executionContext) {
|
||||
// Each host owns its own temp wrapper and job_host lifecycle for per-target evidence.
|
||||
db.prepare('UPDATE job_hosts SET status = ?, started_at = ? WHERE job_id = ? AND host_id = ?').run('running', now(), jobId, host.id);
|
||||
appendLog(jobId, host.id, 'system', `Starting ${script.name} on ${host.name} (${host.transport})`);
|
||||
|
||||
if (!scriptExecutionAllowed()) {
|
||||
appendLog(jobId, host.id, 'stderr', 'Script execution is disabled by ALLOW_SCRIPT_EXECUTION=false.');
|
||||
finishHost(jobId, host.id, 'failed', 126);
|
||||
return;
|
||||
}
|
||||
|
||||
const secret = decryptSecret(host);
|
||||
const wrapper = buildWrapper(script.content, host, executionContext);
|
||||
const file = path.join(os.tmpdir(), `poshmanager-${jobId}-${host.id}.ps1`);
|
||||
|
||||
try {
|
||||
await fs.writeFile(file, wrapper, { mode: 0o600 });
|
||||
await spawnPowerShell(jobId, host, file, secret, executionContext);
|
||||
} catch (error) {
|
||||
appendLog(jobId, host.id, 'stderr', error.message);
|
||||
finishHost(jobId, host.id, 'failed', 1);
|
||||
} finally {
|
||||
await fs.rm(file, { force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function buildWrapper(content, host, executionContext = {}) {
|
||||
// Build PowerShell wrappers without shell interpolation; host values are quoted as PS literals.
|
||||
const runtimeVariables = runtimeVariableValues(host, executionContext);
|
||||
const customVariables = executionContext.customVariables || [];
|
||||
const assetVariables = (executionContext.assets || []).map((asset) => ({
|
||||
name: `asset:${asset.id}`,
|
||||
value: asset.localPath,
|
||||
valueType: 'string'
|
||||
}));
|
||||
const rendered = renderTokens(content, [...runtimeVariables, ...customVariables, ...assetVariables]);
|
||||
const scriptWithPrelude = `${buildVariablePrelude(runtimeVariables, customVariables)}\n${rendered}`;
|
||||
const escaped = scriptWithPrelude.replace(/'@/g, "'`@");
|
||||
if (host.transport === 'local') {
|
||||
return [
|
||||
'$ErrorActionPreference = "Continue"',
|
||||
`$scriptText = @'`,
|
||||
escaped,
|
||||
"'@",
|
||||
'$scriptBlock = [ScriptBlock]::Create($scriptText)',
|
||||
'& $scriptBlock'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
if (host.transport === 'ssh') {
|
||||
return [
|
||||
'$ErrorActionPreference = "Continue"',
|
||||
`$scriptText = @'`,
|
||||
escaped,
|
||||
"'@",
|
||||
'$scriptBlock = [ScriptBlock]::Create($scriptText)',
|
||||
`Invoke-Command -HostName ${psString(host.address)} -UserName $env:POSHM_USERNAME -ScriptBlock $scriptBlock`
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
return [
|
||||
'$ErrorActionPreference = "Continue"',
|
||||
'$securePassword = ConvertTo-SecureString $env:POSHM_SECRET -AsPlainText -Force',
|
||||
'$credential = [System.Management.Automation.PSCredential]::new($env:POSHM_USERNAME, $securePassword)',
|
||||
`$scriptText = @'`,
|
||||
escaped,
|
||||
"'@",
|
||||
'$scriptBlock = [ScriptBlock]::Create($scriptText)',
|
||||
`Invoke-Command -ComputerName ${psString(host.address)} -Credential $credential -ScriptBlock $scriptBlock`
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function spawnPowerShell(jobId, host, file, secret, executionContext = {}) {
|
||||
// Secrets are passed through process environment variables and are never written to job logs.
|
||||
const runtimeEnv = Object.fromEntries(runtimeVariableValues(host, executionContext).map((variable) => [variable.name, String(variable.value ?? '')]));
|
||||
const assetManifest = JSON.stringify(executionContext.assets || []);
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(config.powershellBin, ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', file], {
|
||||
env: {
|
||||
...process.env,
|
||||
...runtimeEnv,
|
||||
POSHM_HOST_ID: host.id,
|
||||
POSHM_HOST_NAME: host.name,
|
||||
POSHM_HOST_ADDRESS: host.address,
|
||||
POSHM_HOST_TRANSPORT: host.transport,
|
||||
POSHM_ASSET_MANIFEST: assetManifest,
|
||||
POSHM_USERNAME: host.credential_username || '',
|
||||
POSHM_SECRET: secret || ''
|
||||
},
|
||||
shell: false
|
||||
});
|
||||
|
||||
child.stdout.on('data', (chunk) => appendChunk(jobId, host.id, 'stdout', chunk));
|
||||
child.stderr.on('data', (chunk) => appendChunk(jobId, host.id, 'stderr', chunk));
|
||||
child.on('error', (error) => {
|
||||
appendLog(jobId, host.id, 'stderr', `Unable to start PowerShell: ${error.message}`);
|
||||
finishHost(jobId, host.id, 'failed', 127);
|
||||
resolve();
|
||||
});
|
||||
child.on('close', (code, signal) => {
|
||||
if (signal) appendLog(jobId, host.id, 'stderr', `PowerShell terminated by signal ${signal}`);
|
||||
const exitCode = code ?? 1;
|
||||
finishHost(jobId, host.id, exitCode === 0 ? 'completed' : 'failed', exitCode);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function appendChunk(jobId, hostId, stream, chunk) {
|
||||
String(chunk)
|
||||
.split(/\r?\n/)
|
||||
.filter(Boolean)
|
||||
.forEach((line) => appendLog(jobId, hostId, stream, line));
|
||||
}
|
||||
|
||||
function appendLog(jobId, hostId, stream, line) {
|
||||
db.prepare(`
|
||||
INSERT INTO job_logs (id, job_id, host_id, stream, line, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(id('log'), jobId, hostId, stream, line, now());
|
||||
}
|
||||
|
||||
function finishHost(jobId, hostId, status, exitCode) {
|
||||
db.prepare(`
|
||||
UPDATE job_hosts SET status = ?, exit_code = ?, ended_at = ?
|
||||
WHERE job_id = ? AND host_id = ?
|
||||
`).run(status, exitCode, now(), jobId, hostId);
|
||||
appendLog(jobId, hostId, 'system', `Finished with status ${status} and exit code ${exitCode}`);
|
||||
}
|
||||
|
||||
function runtimeVariableValues(host, executionContext) {
|
||||
const assetManifest = JSON.stringify(executionContext.assets || []);
|
||||
return [
|
||||
{ name: 'POSHM_JOB_ID', value: executionContext.jobId || '', valueType: 'string' },
|
||||
{ name: 'POSHM_RUNPLAN_ID', value: executionContext.runplanId || '', valueType: 'string' },
|
||||
{ name: 'POSHM_SCRIPT_ID', value: executionContext.scriptId || '', valueType: 'string' },
|
||||
{ name: 'POSHM_SCRIPT_NAME', value: executionContext.scriptName || '', valueType: 'string' },
|
||||
{ name: 'POSHM_HOST_ID', value: host.id, valueType: 'string' },
|
||||
{ name: 'POSHM_HOST_NAME', value: host.name, valueType: 'string' },
|
||||
{ name: 'POSHM_HOST_ADDRESS', value: host.address, valueType: 'string' },
|
||||
{ name: 'POSHM_HOST_TRANSPORT', value: host.transport, valueType: 'string' },
|
||||
{ name: 'POSHM_ASSET_MANIFEST', value: assetManifest, valueType: 'string' },
|
||||
{ name: 'POSHM_TRIGGERED_BY', value: executionContext.triggeredBy || '', valueType: 'string' }
|
||||
];
|
||||
}
|
||||
|
||||
function buildVariablePrelude(runtimeVariables, customVariables) {
|
||||
const envLines = runtimeVariables.map((variable) => `$env:${variable.name} = ${psLiteral(variable.value, variable.valueType)}`);
|
||||
const setLines = [...runtimeVariables, ...customVariables]
|
||||
.filter((variable) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(variable.name))
|
||||
.map((variable) => `Set-Variable -Name ${psString(variable.name)} -Value ${psLiteral(variable.value, variable.valueType)} -Scope Script`);
|
||||
return [
|
||||
'# POSHManager runtime variables',
|
||||
...envLines,
|
||||
...setLines
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function renderTokens(content, variables) {
|
||||
let rendered = String(content || '');
|
||||
for (const variable of variables) {
|
||||
const token = `{{${variable.name}}}`;
|
||||
rendered = rendered.split(token).join(String(variable.value ?? ''));
|
||||
}
|
||||
return rendered;
|
||||
}
|
||||
|
||||
function psLiteral(value, type = 'string') {
|
||||
if (type === 'boolean') return String(value).toLowerCase() === 'true' || value === true ? '$true' : '$false';
|
||||
if (type === 'number' && value !== '' && Number.isFinite(Number(value))) return String(Number(value));
|
||||
if (type === 'json') return `(ConvertFrom-Json -InputObject ${psString(value)})`;
|
||||
return psString(value);
|
||||
}
|
||||
|
||||
function psString(value) {
|
||||
return `'${String(value || '').replace(/'/g, "''")}'`;
|
||||
}
|
||||
73
server/services/promotionWorker.js
Normal file
73
server/services/promotionWorker.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { applyPromotion, listPromotionCandidates } from '../models/psadtModel.js';
|
||||
import { getGraphConnectionSystem } from '../models/graphModel.js';
|
||||
import { setRingIntent } from './intunePromotionService.js';
|
||||
import { buildAssignmentsPayload } from './intunePublishService.js';
|
||||
import { assignWin32App } from './graphService.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
// Scheduled auto-promotion: after a deployment's soak window elapses, advance
|
||||
// its configured rollout ring's intent. When the linked Graph connection is
|
||||
// write-enabled and not approval-gated, the new assignments are also pushed to
|
||||
// the tenant; otherwise the plan is promoted and an operator pushes it.
|
||||
|
||||
// Pure eligibility decision so the timing logic is unit-tested.
|
||||
export function evaluatePromotion(deployment, nowMs = Date.now()) {
|
||||
const hours = Number(deployment.autoPromoteAfterHours) || 0;
|
||||
if (hours <= 0) return { due: false, reason: 'auto-promote disabled' };
|
||||
if (!deployment.autoPromoteRing) return { due: false, reason: 'no target ring' };
|
||||
if (deployment.promotedAt) return { due: false, reason: 'already promoted' };
|
||||
if (!deployment.soakStartedAt) return { due: false, reason: 'soak not started' };
|
||||
const soakStart = Date.parse(deployment.soakStartedAt);
|
||||
if (Number.isNaN(soakStart)) return { due: false, reason: 'invalid soak timestamp' };
|
||||
const elapsedHours = (nowMs - soakStart) / 3_600_000;
|
||||
if (elapsedHours < hours) return { due: false, reason: `soaking (${elapsedHours.toFixed(1)}/${hours}h)` };
|
||||
return { due: true, reason: 'soak window elapsed' };
|
||||
}
|
||||
|
||||
export async function runAutoPromotions({ nowMs = Date.now(), pusher = assignWin32App } = {}) {
|
||||
const summary = { evaluated: 0, promoted: 0, pushed: 0, errors: 0 };
|
||||
for (const deployment of listPromotionCandidates()) {
|
||||
summary.evaluated += 1;
|
||||
const decision = evaluatePromotion(deployment, nowMs);
|
||||
if (!decision.due) continue;
|
||||
try {
|
||||
const { assignments, changed } = setRingIntent(deployment.assignments, deployment.autoPromoteRing, deployment.autoPromoteIntent);
|
||||
applyPromotion(deployment.id, changed ? assignments : deployment.assignments);
|
||||
summary.promoted += 1;
|
||||
|
||||
// Optionally push to the tenant when it is safe to do so automatically.
|
||||
if (deployment.graphAppId && deployment.graphConnectionId) {
|
||||
const connection = getGraphConnectionSystem(deployment.graphConnectionId);
|
||||
if (connection?.allow_write && !connection?.require_approval) {
|
||||
const built = buildAssignmentsPayload({ ...deployment, assignments });
|
||||
await pusher(connection, deployment.graphAppId, built.assignments);
|
||||
summary.pushed += 1;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
summary.errors += 1;
|
||||
logger.error('auto-promotion failed', { deploymentId: deployment.id, error: error.message });
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
let timer = null;
|
||||
|
||||
export function startPromotionWorker() {
|
||||
const minutes = Number(process.env.AUTO_PROMOTE_INTERVAL_MINUTES || 0);
|
||||
if (!Number.isFinite(minutes) || minutes <= 0) return null;
|
||||
timer = setInterval(() => {
|
||||
runAutoPromotions()
|
||||
.then((summary) => logger.info('auto-promotion run complete', summary))
|
||||
.catch((error) => logger.error('auto-promotion run failed', { error: error.message }));
|
||||
}, minutes * 60 * 1000);
|
||||
timer.unref?.();
|
||||
logger.info(`auto-promotion scheduled every ${minutes} minute(s)`);
|
||||
return timer;
|
||||
}
|
||||
|
||||
export function stopPromotionWorker() {
|
||||
if (timer) clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
148
server/services/psadtMigrationService.js
Normal file
148
server/services/psadtMigrationService.js
Normal file
@@ -0,0 +1,148 @@
|
||||
const replacements = [
|
||||
{ id: 'MIG-FN-EXECUTE-PROCESS', label: 'Execute-Process to Start-ADTProcess', pattern: /\bExecute-Process\b/g, replacement: 'Start-ADTProcess', automatic: true },
|
||||
{ id: 'MIG-FN-EXECUTE-MSI', label: 'Execute-MSI to Start-ADTMsiProcess', pattern: /\bExecute-MSI\b/g, replacement: 'Start-ADTMsiProcess', automatic: true },
|
||||
{ id: 'MIG-FN-EXECUTE-MSP', label: 'Execute-MSP to Start-ADTMspProcess', pattern: /\bExecute-MSP\b/g, replacement: 'Start-ADTMspProcess', automatic: true },
|
||||
{ id: 'MIG-FN-WELCOME', label: 'Show-InstallationWelcome to Show-ADTInstallationWelcome', pattern: /\bShow-InstallationWelcome\b/g, replacement: 'Show-ADTInstallationWelcome', automatic: true },
|
||||
{ id: 'MIG-FN-PROMPT', label: 'Show-InstallationPrompt to Show-ADTInstallationPrompt', pattern: /\bShow-InstallationPrompt\b/g, replacement: 'Show-ADTInstallationPrompt', automatic: true },
|
||||
{ id: 'MIG-FN-PROGRESS', label: 'Show-InstallationProgress to Show-ADTInstallationProgress', pattern: /\bShow-InstallationProgress\b/g, replacement: 'Show-ADTInstallationProgress', automatic: true },
|
||||
{ id: 'MIG-FN-RESTART', label: 'Show-InstallationRestartPrompt to Show-ADTInstallationRestartPrompt', pattern: /\bShow-InstallationRestartPrompt\b/g, replacement: 'Show-ADTInstallationRestartPrompt', automatic: true },
|
||||
{ id: 'MIG-FN-LOG', label: 'Write-Log to Write-ADTLogEntry', pattern: /\bWrite-Log\b/g, replacement: 'Write-ADTLogEntry', automatic: true },
|
||||
{ id: 'MIG-FN-REMOVE-MSI', label: 'Remove-MSIApplications to Uninstall-ADTApplication', pattern: /\bRemove-MSIApplications\b/g, replacement: 'Uninstall-ADTApplication', automatic: true },
|
||||
{ id: 'MIG-FN-HKCU', label: 'Invoke-HKCURegistrySettingsForAllUsers to Invoke-ADTAllUsersRegistryAction', pattern: /\bInvoke-HKCURegistrySettingsForAllUsers\b/g, replacement: 'Invoke-ADTAllUsersRegistryAction', automatic: true },
|
||||
{ id: 'MIG-NAME-DEPLOY-PS1', label: 'Deploy-Application.ps1 to Invoke-AppDeployToolkit.ps1', pattern: /\bDeploy-Application\.ps1\b/g, replacement: 'Invoke-AppDeployToolkit.ps1', automatic: true },
|
||||
{ id: 'MIG-NAME-DEPLOY-EXE', label: 'Deploy-Application.exe to Invoke-AppDeployToolkit.exe', pattern: /\bDeploy-Application\.exe\b/g, replacement: 'Invoke-AppDeployToolkit.exe', automatic: true },
|
||||
{ id: 'MIG-VAR-APPNAME', label: '$appName to $adtSession.AppName', pattern: /\$appName\b/g, replacement: '$adtSession.AppName', automatic: true },
|
||||
{ id: 'MIG-VAR-APPVERSION', label: '$appVersion to $adtSession.AppVersion', pattern: /\$appVersion\b/g, replacement: '$adtSession.AppVersion', automatic: true },
|
||||
{ id: 'MIG-VAR-APPVENDOR', label: '$appVendor to $adtSession.AppVendor', pattern: /\$appVendor\b/g, replacement: '$adtSession.AppVendor', automatic: true },
|
||||
{ id: 'MIG-VAR-APPARCH', label: '$appArch to $adtSession.AppArch', pattern: /\$appArch\b/g, replacement: '$adtSession.AppArch', automatic: true },
|
||||
{ id: 'MIG-VAR-APPLANG', label: '$appLang to $adtSession.AppLang', pattern: /\$appLang\b/g, replacement: '$adtSession.AppLang', automatic: true },
|
||||
{ id: 'MIG-VAR-APPREVISION', label: '$appRevision to $adtSession.AppRevision', pattern: /\$appRevision\b/g, replacement: '$adtSession.AppRevision', automatic: true },
|
||||
{ id: 'MIG-MODULE-420', label: 'Pin PSAppDeployToolkit ModuleVersion to 4.2.0', pattern: /ModuleVersion\s*=\s*['"][0-9.]+['"]/g, replacement: "ModuleVersion = '4.2.0'", automatic: true }
|
||||
];
|
||||
|
||||
const manualChecks = [
|
||||
{
|
||||
id: 'MANUAL-SERVICEUI',
|
||||
severity: 'critical',
|
||||
pattern: /\b(ServiceUI\.exe|Invoke-ServiceUI\.ps1)\b/i,
|
||||
title: 'Remove ServiceUI launch wrappers',
|
||||
guidance: 'PSADT v4.1+ provides native user-session UI for Intune. Remove ServiceUI and test with SYSTEM context launch helpers.'
|
||||
},
|
||||
{
|
||||
id: 'MANUAL-TEMPLATE-SHAPE',
|
||||
severity: 'warning',
|
||||
pattern: /function\s+(Install|Uninstall|Repair)-ADTDeployment/i,
|
||||
title: 'Older function-based deployment phase shape detected',
|
||||
guidance: 'Review the migrated script against the upstream v4 template that uses New-Variable phase scriptblocks and Open-ADTSession.'
|
||||
},
|
||||
{
|
||||
id: 'MANUAL-RAW-MSIEXEC',
|
||||
severity: 'warning',
|
||||
pattern: /\bmsiexec(?:\.exe)?\b/i,
|
||||
title: 'Raw msiexec call needs installer review',
|
||||
guidance: 'Prefer Start-ADTMsiProcess or Start-ADTMspProcess so logging, success codes, and reboot handling stay consistent.'
|
||||
},
|
||||
{
|
||||
id: 'MANUAL-INTUNE-DEFER',
|
||||
severity: 'warning',
|
||||
pattern: /Show-ADTInstallationWelcome[^\n\r]*-AllowDefer\b(?![^\n\r]*-DeferRunInterval)/i,
|
||||
title: 'Intune deferral flow should include DeferRunInterval',
|
||||
guidance: 'Add -DeferRunInterval to avoid repeated Intune retry prompts after a 1602 defer result.'
|
||||
}
|
||||
];
|
||||
|
||||
export function planPsadtMigration(content = '', options = {}) {
|
||||
const source = String(content || '');
|
||||
const steps = [];
|
||||
let automaticCount = 0;
|
||||
|
||||
for (const rule of replacements) {
|
||||
const matches = [...source.matchAll(rule.pattern)].filter((match) => !isCommentLine(source, match.index || 0));
|
||||
if (!matches.length) continue;
|
||||
automaticCount += matches.length;
|
||||
steps.push({
|
||||
id: rule.id,
|
||||
type: 'replace',
|
||||
automatic: rule.automatic,
|
||||
status: 'ready',
|
||||
title: rule.label,
|
||||
count: matches.length,
|
||||
lines: matches.slice(0, 8).map((match) => lineNumber(source, match.index || 0)),
|
||||
replacement: rule.replacement
|
||||
});
|
||||
}
|
||||
|
||||
for (const rule of manualChecks) {
|
||||
const match = rule.pattern.exec(source);
|
||||
if (!match || isCommentLine(source, match.index || 0)) continue;
|
||||
steps.push({
|
||||
id: rule.id,
|
||||
type: 'manual-review',
|
||||
automatic: false,
|
||||
status: 'review',
|
||||
severity: rule.severity,
|
||||
title: rule.title,
|
||||
guidance: rule.guidance,
|
||||
lines: [lineNumber(source, match.index || 0)]
|
||||
});
|
||||
}
|
||||
|
||||
if (!/Open-ADTSession/i.test(source)) {
|
||||
steps.push({
|
||||
id: 'MANUAL-OPEN-ADTSESSION',
|
||||
type: 'manual-review',
|
||||
automatic: false,
|
||||
status: 'review',
|
||||
severity: 'warning',
|
||||
title: 'Open-ADTSession not detected',
|
||||
guidance: 'Latest PSADT scripts should initialize the module and open an ADTSession before invoking phase logic.',
|
||||
lines: []
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
targetVersion: options.targetVersion || '4.2.0',
|
||||
summary: {
|
||||
steps: steps.length,
|
||||
automatic: steps.filter((step) => step.automatic).length,
|
||||
manual: steps.filter((step) => !step.automatic).length,
|
||||
replacements: automaticCount
|
||||
},
|
||||
steps,
|
||||
migratedContent: applyPsadtMigration(source)
|
||||
};
|
||||
}
|
||||
|
||||
export function applyPsadtMigration(content = '') {
|
||||
let migrated = String(content || '');
|
||||
for (const rule of replacements) {
|
||||
migrated = replaceOutsideComments(migrated, rule.pattern, rule.replacement);
|
||||
}
|
||||
migrated = migrated.replace(/(Show-ADTInstallationPrompt[^\n\r]*)\s-Icon\s+\S+/gi, '$1');
|
||||
if (!/POSHManager PSADT migration notes/i.test(migrated)) {
|
||||
migrated = [
|
||||
'# POSHManager PSADT migration notes:',
|
||||
'# - Automatic replacements were applied for known v3/v4 legacy names and variables.',
|
||||
'# - Review manual migration findings before deploying through Intune or any production channel.',
|
||||
'',
|
||||
migrated
|
||||
].join('\n');
|
||||
}
|
||||
return migrated;
|
||||
}
|
||||
|
||||
function replaceOutsideComments(content, pattern, replacement) {
|
||||
return content.split(/\r?\n/).map((line) => {
|
||||
if (line.trimStart().startsWith('#')) return line;
|
||||
return line.replace(pattern, replacement);
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
function isCommentLine(content, index) {
|
||||
const line = content.slice(0, index).split(/\r?\n/).length;
|
||||
return content.split(/\r?\n/)[line - 1]?.trimStart().startsWith('#');
|
||||
}
|
||||
|
||||
function lineNumber(content, index) {
|
||||
return content.slice(0, index).split(/\r?\n/).length;
|
||||
}
|
||||
247
server/services/psadtValidator.js
Normal file
247
server/services/psadtValidator.js
Normal file
@@ -0,0 +1,247 @@
|
||||
import { psadtValidationRules } from '../data/psadtValidationRules.js';
|
||||
|
||||
const ruleById = new Map(psadtValidationRules.map((rule) => [rule.id, rule]));
|
||||
const legacyFunctions = [
|
||||
'Execute-Process',
|
||||
'Execute-MSI',
|
||||
'Execute-MSP',
|
||||
'Show-InstallationWelcome',
|
||||
'Show-InstallationPrompt',
|
||||
'Show-InstallationProgress',
|
||||
'Show-InstallationRestartPrompt',
|
||||
'Write-Log',
|
||||
'Remove-MSIApplications',
|
||||
'Invoke-HKCURegistrySettingsForAllUsers'
|
||||
];
|
||||
|
||||
export function validationRuleCatalog() {
|
||||
return psadtValidationRules;
|
||||
}
|
||||
|
||||
export function validatePsadtScript(content = '', context = {}) {
|
||||
const text = String(content || '');
|
||||
const findings = [
|
||||
...checkPowerShellRequirement(text),
|
||||
...checkRequireAdminConfig(text),
|
||||
...checkRemovedDetectionConfig(text),
|
||||
...checkAppProcessesToClose(text),
|
||||
...checkSessionCleanup(text),
|
||||
...checkModuleVersionDrift(text),
|
||||
...checkDeployModeAuto(text),
|
||||
...checkLegacyFunctions(text),
|
||||
...checkLegacyDeploymentNaming(text),
|
||||
...checkLegacyDeploymentVariables(text),
|
||||
...checkEnvironmentVariables(text),
|
||||
...checkServiceUi(text),
|
||||
...checkDeferRunInterval(text, context),
|
||||
...checkPromptIcon(text),
|
||||
...checkRawMsiExec(text),
|
||||
...checkStartProcessWait(text),
|
||||
...checkExitCodes(text),
|
||||
...checkGlobalModuleImport(text),
|
||||
...checkDeprecatedObjectProperty(text),
|
||||
...checkUserContextRequireAdmin(text, context)
|
||||
].map((finding, index) => ({
|
||||
id: `${finding.ruleId}-${index + 1}`,
|
||||
...ruleById.get(finding.ruleId),
|
||||
...finding
|
||||
}));
|
||||
|
||||
return {
|
||||
ok: findings.every((finding) => !['critical', 'error'].includes(finding.severity)),
|
||||
score: scoreFindings(findings),
|
||||
counts: countFindings(findings),
|
||||
findings,
|
||||
rules: psadtValidationRules,
|
||||
analyzedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
function add(ruleId, text, match, extra = {}) {
|
||||
return {
|
||||
ruleId,
|
||||
line: lineNumber(text, match.index ?? 0),
|
||||
excerpt: lineText(text, match.index ?? 0),
|
||||
...extra
|
||||
};
|
||||
}
|
||||
|
||||
function checkPowerShellRequirement(text) {
|
||||
const findings = [];
|
||||
for (const match of text.matchAll(/#requires\s+-version\s+([0-9.]+)/gi)) {
|
||||
const version = Number.parseFloat(match[1]);
|
||||
if (Number.isFinite(version) && version < 5.1) findings.push(add('REQ-PSVERSION-MINIMUM', text, match, { detected: match[0] }));
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function checkRequireAdminConfig(text) {
|
||||
const findings = [];
|
||||
const configPattern = /(Toolkit\.)?RequireAdmin\s*=/gi;
|
||||
for (const match of text.matchAll(configPattern)) {
|
||||
const nearby = text.slice(Math.max(0, match.index - 80), match.index + 160);
|
||||
if (!/\$adtSession\s*=|@\{|RequireAdmin\s*=\s*\$(true|false)/i.test(nearby) || /Toolkit\.RequireAdmin/i.test(match[0])) {
|
||||
findings.push(add('UPG-REQUIREADMIN-CONFIG', text, match));
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function checkRemovedDetectionConfig(text) {
|
||||
return [...text.matchAll(/\b(Toolkit\.)?(OOBEDetection|SessionDetection)\b/gi)]
|
||||
.map((match) => add('UPG-REMOVED-CONFIG-DETECTION', text, match, { detected: match[0] }));
|
||||
}
|
||||
|
||||
function checkAppProcessesToClose(text) {
|
||||
if (/AppProcessesToClose/i.test(text)) return [];
|
||||
return [...text.matchAll(/Show-ADTInstallationWelcome[^\n\r]*-CloseProcesses\s+(?!\$adtSession\.AppProcessesToClose)/gi)]
|
||||
.map((match) => add('UPG-APP-PROCESSES-SESSION', text, match));
|
||||
}
|
||||
|
||||
function checkSessionCleanup(text) {
|
||||
if (!/Open-ADTSession/i.test(text)) return [];
|
||||
if (/Remove-ADTHashtableNullOrEmptyValues/i.test(text)) return [];
|
||||
const match = /Open-ADTSession/i.exec(text);
|
||||
return match ? [add('UPG-MISSING-SESSION-CLEANUP', text, match)] : [];
|
||||
}
|
||||
|
||||
function checkLegacyFunctions(text) {
|
||||
const pattern = new RegExp(`\\b(${legacyFunctions.map(escapeRegExp).join('|')})\\b`, 'gi');
|
||||
return [...text.matchAll(pattern)]
|
||||
.filter((match) => !isCommentMatch(text, match))
|
||||
.map((match) => add('LEGACY-V3-FUNCTION', text, match, { detected: match[0] }));
|
||||
}
|
||||
|
||||
function checkLegacyDeploymentNaming(text) {
|
||||
return [...text.matchAll(/\bDeploy-Application\.(ps1|exe)\b/gi)]
|
||||
.filter((match) => !isCommentMatch(text, match))
|
||||
.map((match) => add('LEGACY-DEPLOY-APPLICATION', text, match));
|
||||
}
|
||||
|
||||
function checkLegacyDeploymentVariables(text) {
|
||||
return [...text.matchAll(/\$(appName|appVersion|appVendor|appArch|appLang|appRevision)\b/g)]
|
||||
.map((match) => add('LEGACY-DEPLOYMENT-VARIABLE', text, match, { detected: match[0] }));
|
||||
}
|
||||
|
||||
function checkEnvironmentVariables(text) {
|
||||
if (/Open-ADTSession|Export-ADTEnvironmentTableToSessionState/i.test(text)) return [];
|
||||
return [...text.matchAll(/\$(envProgramFiles|envProgramFilesX86|envSystemRoot|envWinDir|envTemp)\b/g)]
|
||||
.map((match) => add('FAQ-ENV-VAR-BEFORE-SESSION', text, match, { detected: match[0] }));
|
||||
}
|
||||
|
||||
function checkServiceUi(text) {
|
||||
return [...text.matchAll(/\b(ServiceUI\.exe|Invoke-ServiceUI\.ps1)\b/gi)]
|
||||
.filter((match) => !isCommentMatch(text, match))
|
||||
.map((match) => add('INTUNE-SERVICEUI', text, match));
|
||||
}
|
||||
|
||||
function checkDeferRunInterval(text, context) {
|
||||
const findings = [];
|
||||
const intuneHint = context.platform === 'intune' || /Intune|IME|Microsoft Intune/i.test(text);
|
||||
for (const match of text.matchAll(/Show-ADTInstallationWelcome[^\n\r]*-AllowDefer\b(?![^\n\r]*-DeferRunInterval)/gi)) {
|
||||
if (intuneHint || /Invoke-AppDeployToolkit\.exe|DeployMode/i.test(text)) findings.push(add('INTUNE-DEFER-RUN-INTERVAL', text, match));
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function checkPromptIcon(text) {
|
||||
return [...text.matchAll(/Show-ADTInstallationPrompt[^\n\r]*\s-Icon\b/gi)]
|
||||
.filter((match) => !isCommentMatch(text, match))
|
||||
.map((match) => add('UI-PROMPT-ICON-FLUENT', text, match));
|
||||
}
|
||||
|
||||
function checkRawMsiExec(text) {
|
||||
return [...text.matchAll(/\bmsiexec(?:\.exe)?\b[^\n\r]*(\/i|\/x|\/f|\/p)/gi)]
|
||||
.filter((match) => !isCommentMatch(text, match))
|
||||
.map((match) => add('INSTALL-RAW-MSIEXEC', text, match));
|
||||
}
|
||||
|
||||
function checkStartProcessWait(text) {
|
||||
return [...text.matchAll(/Start-ADTProcess[^\n\r]*-FilePath\s+['"][^'"]+\.(exe|cmd|bat)['"][^\n\r]*/gi)]
|
||||
.filter((match) => !/-WaitForChildProcesses\b/i.test(match[0]) && !/-NoWait\b/i.test(match[0]))
|
||||
.map((match) => add('INSTALL-PROCESS-NO-WAIT-CHILD', text, match));
|
||||
}
|
||||
|
||||
function checkExitCodes(text) {
|
||||
const findings = [];
|
||||
for (const match of text.matchAll(/\b(6[0-9]{4}|7[0-9]{4})\b/g)) {
|
||||
if (isCommentMatch(text, match)) continue;
|
||||
const code = Number(match[1]);
|
||||
if (code >= 60000 && code <= 68999) findings.push(add('EXIT-RESERVED-BUILTIN', text, match, { detected: String(code) }));
|
||||
if (code >= 69000 && code <= 69999) findings.push(add('EXIT-CUSTOM-RANGE', text, match, { detected: String(code) }));
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function checkModuleVersionDrift(text) {
|
||||
const findings = [];
|
||||
for (const match of text.matchAll(/ModuleVersion\s*=\s*['"]([0-9.]+)['"]/gi)) {
|
||||
if (isCommentMatch(text, match)) continue;
|
||||
if (compareVersion(match[1], '4.2.0') < 0) findings.push(add('REPO-MODULE-VERSION-DRIFT', text, match, { detected: match[1] }));
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function checkDeployModeAuto(text) {
|
||||
return [...text.matchAll(/-DeployMode\s+(Interactive|Silent|NonInteractive)\b/gi)]
|
||||
.filter((match) => !isCommentMatch(text, match))
|
||||
.map((match) => add('REPO-DEPLOYMODE-NO-AUTO', text, match, { detected: match[1] }));
|
||||
}
|
||||
|
||||
function checkDeprecatedObjectProperty(text) {
|
||||
return [...text.matchAll(/\bGet-ADTObjectProperty\b/gi)]
|
||||
.filter((match) => !isCommentMatch(text, match))
|
||||
.map((match) => add('DEPRECATED-GET-ADTOBJECTPROPERTY', text, match));
|
||||
}
|
||||
|
||||
function checkGlobalModuleImport(text) {
|
||||
return [...text.matchAll(/Import-Module\s+['"]?PSAppDeployToolkit['"]?(?![\\/])/gi)].map((match) => add('FAQ-GLOBAL-MODULE-IMPORT', text, match));
|
||||
}
|
||||
|
||||
function checkUserContextRequireAdmin(text, context) {
|
||||
if (!/Start-ADTProcessAsUser|Start-ADTMsiProcessAsUser/i.test(text)) return [];
|
||||
if (/RequireAdmin\s*=\s*\$false/i.test(text) || context.requireAdmin === false) return [];
|
||||
const match = /Start-ADTMsiProcessAsUser|Start-ADTProcessAsUser/i.exec(text);
|
||||
return match ? [add('USERCONTEXT-REQUIREADMIN', text, match)] : [];
|
||||
}
|
||||
|
||||
function scoreFindings(findings) {
|
||||
const penalties = { critical: 35, error: 22, warning: 10, info: 3 };
|
||||
const penalty = findings.reduce((total, finding) => total + (penalties[finding.severity] || 0), 0);
|
||||
return Math.max(0, 100 - penalty);
|
||||
}
|
||||
|
||||
function countFindings(findings) {
|
||||
return findings.reduce((counts, finding) => {
|
||||
counts[finding.severity] = (counts[finding.severity] || 0) + 1;
|
||||
counts.total += 1;
|
||||
return counts;
|
||||
}, { total: 0, critical: 0, error: 0, warning: 0, info: 0 });
|
||||
}
|
||||
|
||||
function lineNumber(text, index) {
|
||||
return text.slice(0, index).split(/\r?\n/).length;
|
||||
}
|
||||
|
||||
function lineText(text, index) {
|
||||
const lines = text.split(/\r?\n/);
|
||||
return lines[lineNumber(text, index) - 1]?.trim().slice(0, 240) || '';
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function isCommentMatch(text, match) {
|
||||
return lineText(text, match.index ?? 0).trimStart().startsWith('#');
|
||||
}
|
||||
|
||||
function compareVersion(left, right) {
|
||||
const a = String(left).split('.').map((part) => Number(part) || 0);
|
||||
const b = String(right).split('.').map((part) => Number(part) || 0);
|
||||
for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
|
||||
if ((a[index] || 0) < (b[index] || 0)) return -1;
|
||||
if ((a[index] || 0) > (b[index] || 0)) return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user