This commit is contained in:
2026-06-25 12:05:53 -05:00
commit b58e50e423
141 changed files with 28190 additions and 0 deletions

View File

@@ -0,0 +1,549 @@
import { graphDeploymentLinkSchema, graphDeploymentSyncSchema, intuneDeploymentSchema, psadtMigrationSchema, psadtProfileSchema, psadtValidationSchema } from '../forms/schemas.js';
import { createSyncRun, finishSyncRun, getGraphConnection, listDeploymentStatuses, listDeploymentSyncRuns, listGraphAudit, recordGraphAudit, upsertDeploymentStatuses } from '../models/graphModel.js';
import { createScript, getScript } from '../models/libraryModel.js';
import {
createIntuneDeployment,
createPsadtProfile,
deleteIntuneDeployment,
deletePsadtProfile,
getPsadtCatalog,
listIntuneDeployments,
listPsadtProfiles,
loadIntuneDeployment,
loadPsadtProfile,
markDeploymentPublished,
renderPsadtProfile,
updateIntuneDeployment,
updatePsadtProfile
} from '../models/psadtModel.js';
import fs from 'node:fs';
import path from 'node:path';
import { config } from '../config.js';
import { createChangeRequest, findApprovedRequest, findPendingRequest, markRequestExecuted } from '../models/changeRequestModel.js';
import { buildIntuneWin, builderAvailability } from '../services/intuneWinBuilder.js';
import { assignWin32App, getWin32AppState, setWin32AppRelationships, syncIntuneDeploymentStatus, uploadWin32Content, upsertWin32App } from '../services/graphService.js';
import { buildAssignmentsPayload, buildRelationshipsPayload, buildWin32LobAppPayload } from '../services/intunePublishService.js';
import { computeDrift } from '../services/intuneDriftService.js';
import { canPublish } from '../services/graphRbac.js';
import { cloneForNextVersion, setRingIntent } from '../services/intunePromotionService.js';
import { parseIntunewin } from '../services/intunewinParser.js';
import { createAssetFromUpload, getAssetFile } from '../models/assetModel.js';
import { validatePsadtScript, validationRuleCatalog } from '../services/psadtValidator.js';
import { planPsadtMigration } from '../services/psadtMigrationService.js';
export function catalog(req, res) {
res.json(getPsadtCatalog());
}
export function intuneIndex(req, res) {
res.json(listIntuneDeployments(req.user.id));
}
export function intuneCreate(req, res) {
const parsed = intuneDeploymentSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid Intune deployment payload' });
res.status(201).json(createIntuneDeployment(parsed.data, req.user.id));
}
export function intuneShow(req, res) {
const deployment = loadIntuneDeployment(req.params.id, req.user.id);
if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' });
res.json(deployment);
}
export function intuneUpdate(req, res) {
const parsed = intuneDeploymentSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid Intune deployment payload' });
const deployment = updateIntuneDeployment(req.params.id, parsed.data, req.user.id);
if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' });
res.json(deployment);
}
export function intuneDestroy(req, res) {
deleteIntuneDeployment(req.params.id, req.user.id);
res.status(204).end();
}
export function intuneGraphStatus(req, res) {
const deployment = loadIntuneDeployment(req.params.id, req.user.id);
if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' });
const statuses = listDeploymentStatuses(deployment.id);
const syncRuns = listDeploymentSyncRuns(deployment.id);
res.json({
deployment,
summary: summarizeInstallStatuses(statuses),
failures: statuses.filter((row) => row.installState === 'failed' || row.errorCode),
statuses,
syncRuns
});
}
export function intuneGraphLink(req, res) {
const parsed = graphDeploymentLinkSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Valid connectionId and graphAppId are required' });
const deployment = loadIntuneDeployment(req.params.id, req.user.id);
if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' });
const updated = updateIntuneDeployment(req.params.id, {
...deployment,
graphConnectionId: parsed.data.connectionId || deployment.graphConnectionId || null,
graphAppId: parsed.data.graphAppId
}, req.user.id);
res.json(updated);
}
export async function intuneGraphSync(req, res) {
const parsed = graphDeploymentSyncSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid Graph sync payload' });
const deployment = loadIntuneDeployment(req.params.id, req.user.id);
if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' });
const connectionId = parsed.data.connectionId || deployment.graphConnectionId;
const graphAppId = parsed.data.graphAppId || deployment.graphAppId;
if (!connectionId || !graphAppId) return res.status(400).json({ error: 'Graph connection and Intune mobile app id are required' });
const connection = getGraphConnection(connectionId, req.user.id, true);
if (!connection) return res.status(404).json({ error: 'Graph connection not found' });
const runId = createSyncRun(deployment.id, connectionId, graphAppId);
try {
const result = await syncIntuneDeploymentStatus(connection, graphAppId, parsed.data.mode);
upsertDeploymentStatuses(deployment.id, connectionId, graphAppId, result.rows);
finishSyncRun(runId, 'success', `Synced ${result.rows.length} status rows from ${result.sources.join(', ') || 'Graph'}`, result.summary);
const updated = updateIntuneDeployment(req.params.id, {
...deployment,
graphConnectionId: connectionId,
graphAppId
}, req.user.id);
const statuses = listDeploymentStatuses(deployment.id);
res.json({
deployment: updated,
summary: summarizeInstallStatuses(statuses),
failures: statuses.filter((row) => row.installState === 'failed' || row.errorCode),
statuses,
syncRuns: listDeploymentSyncRuns(deployment.id)
});
} catch (error) {
finishSyncRun(runId, 'failed', error.message, {});
res.status(400).json({ error: error.message, syncRuns: listDeploymentSyncRuns(deployment.id) });
}
}
// Shared resolver for tenant-changing Graph actions: loads the visible
// deployment and a write-enabled Graph connection (with secret), or sends the
// appropriate error and returns null.
function resolveWriteContext(req, res) {
const parsed = graphDeploymentSyncSchema.safeParse(req.body || {});
const deployment = loadIntuneDeployment(req.params.id, req.user.id);
if (!deployment) { res.status(404).json({ error: 'Intune deployment not found' }); return null; }
const connectionId = (parsed.success && parsed.data.connectionId) || deployment.graphConnectionId;
if (!connectionId) { res.status(400).json({ error: 'A Graph connection is required for tenant write-back' }); return null; }
const connection = getGraphConnection(connectionId, req.user.id, true);
if (!connection) { res.status(404).json({ error: 'Graph connection not found' }); return null; }
if (!connection.allow_write) {
res.status(403).json({ error: 'This Graph connection is read-only. An admin must enable write-back first.' });
return null;
}
if (!canPublish(connection, req.user)) {
res.status(403).json({ error: 'You are not an authorized publisher for this Graph connection.' });
return null;
}
return { deployment, connection, connectionId };
}
// Governance gate: when a connection requires approval, a tenant-changing action
// only proceeds if an approved (not-yet-executed) change request exists for it.
// Otherwise a pending request is created and the caller is told to wait. Returns
// { proceed, requestId } — when proceed is false a 202 response has been sent.
function ensureApproved(ctx, action, req, res) {
if (!ctx.connection.require_approval) return { proceed: true, requestId: null };
const approved = findApprovedRequest(ctx.deployment.id, action);
if (approved) return { proceed: true, requestId: approved.id };
const request = findPendingRequest(ctx.deployment.id, action) || createChangeRequest({
deploymentId: ctx.deployment.id,
connectionId: ctx.connectionId,
action,
requestedBy: req.user.id,
requesterEmail: req.user.email
});
res.status(202).json({ status: 'approval-required', message: 'This action requires approval before it runs.', request });
return { proceed: false };
}
// Phase 1: publish (create/update) the win32LobApp metadata in the tenant from
// a deployment plan. Every attempt is audit-logged with actor, request, response.
export async function intuneGraphPublish(req, res) {
const ctx = resolveWriteContext(req, res);
if (!ctx) return;
const approval = ensureApproved(ctx, 'publish', req, res);
if (!approval.proceed) return;
const { deployment, connection, connectionId } = ctx;
const profile = deployment.profileId ? loadPsadtProfile(deployment.profileId, req.user.id) : null;
const { payload, warnings } = buildWin32LobAppPayload(deployment, profile);
const auditBase = {
connectionId,
deploymentId: deployment.id,
actorUserId: req.user.id,
actorEmail: req.user.email,
action: deployment.graphAppId ? 'win32app.update' : 'win32app.create',
request: payload
};
try {
const result = await upsertWin32App(connection, payload, deployment.graphAppId);
const updated = updateIntuneDeployment(req.params.id, {
...deployment,
graphConnectionId: connectionId,
graphAppId: result.id,
status: 'published'
}, req.user.id);
if (approval.requestId) markRequestExecuted(approval.requestId);
markDeploymentPublished(deployment.id);
recordGraphAudit({ ...auditBase, targetGraphId: result.id, status: 'success', message: result.updated ? 'Updated win32LobApp' : 'Created win32LobApp', response: result.raw || { id: result.id } });
res.json({ deployment: updated, graphAppId: result.id, warnings });
} catch (error) {
recordGraphAudit({ ...auditBase, status: 'failed', message: error.message, response: error.payload || {} });
res.status(400).json({ error: error.message, warnings });
}
}
// Phase 3: replace the app's assignments (rings, filters, exclusions) in tenant.
export async function intuneGraphAssign(req, res) {
const ctx = resolveWriteContext(req, res);
if (!ctx) return;
const { deployment, connection, connectionId } = ctx;
if (!deployment.graphAppId) return res.status(400).json({ error: 'Publish the app before assigning it.' });
const approval = ensureApproved(ctx, 'assign', req, res);
if (!approval.proceed) return;
const { assignments, warnings } = buildAssignmentsPayload(deployment);
const auditBase = {
connectionId, deploymentId: deployment.id, actorUserId: req.user.id, actorEmail: req.user.email,
action: 'win32app.assign', targetGraphId: deployment.graphAppId, request: { mobileAppAssignments: assignments }
};
try {
const result = await assignWin32App(connection, deployment.graphAppId, assignments);
if (approval.requestId) markRequestExecuted(approval.requestId);
recordGraphAudit({ ...auditBase, status: 'success', message: `Assigned ${result.count} target(s)`, response: result });
res.json({ deployment, assigned: result.count, warnings });
} catch (error) {
recordGraphAudit({ ...auditBase, status: 'failed', message: error.message, response: error.payload || {} });
res.status(400).json({ error: error.message, warnings });
}
}
// Phase 4: replace the app's supersedence/dependency relationships in tenant.
export async function intuneGraphRelationships(req, res) {
const ctx = resolveWriteContext(req, res);
if (!ctx) return;
const { deployment, connection, connectionId } = ctx;
if (!deployment.graphAppId) return res.status(400).json({ error: 'Publish the app before setting relationships.' });
const approval = ensureApproved(ctx, 'relationships', req, res);
if (!approval.proceed) return;
let body;
try {
body = buildRelationshipsPayload(deployment.relationships, deployment.graphAppId);
} catch (error) {
return res.status(400).json({ error: error.message, validation: error.validation || [] });
}
const auditBase = {
connectionId, deploymentId: deployment.id, actorUserId: req.user.id, actorEmail: req.user.email,
action: 'win32app.relationships', targetGraphId: deployment.graphAppId, request: body
};
try {
const result = await setWin32AppRelationships(connection, deployment.graphAppId, body.relationships);
if (approval.requestId) markRequestExecuted(approval.requestId);
recordGraphAudit({ ...auditBase, status: 'success', message: `Set ${result.count} relationship(s)`, response: result });
res.json({ deployment, relationships: result.count });
} catch (error) {
recordGraphAudit({ ...auditBase, status: 'failed', message: error.message, response: error.payload || {} });
res.status(400).json({ error: error.message });
}
}
// Phase 2: upload the .intunewin content for a published app. The package is an
// Asset Library file (assetId); we parse it locally for the encrypted payload +
// encryption info, then run the Graph content-version/Azure-block-upload/commit
// flow. NOTE: validated end to end only against a live tenant.
export async function intuneGraphUploadContent(req, res) {
const ctx = resolveWriteContext(req, res);
if (!ctx) return;
const { deployment, connection, connectionId } = ctx;
if (!deployment.graphAppId) return res.status(400).json({ error: 'Publish the app before uploading content.' });
const approval = ensureApproved(ctx, 'upload-content', req, res);
if (!approval.proceed) return;
const assetId = req.body?.assetId;
if (!assetId) return res.status(400).json({ error: 'assetId of the .intunewin package is required' });
const fileRef = getAssetFile(assetId, req.user.id);
if (!fileRef) return res.status(404).json({ error: 'Asset not found' });
let parsed;
try {
parsed = parseIntunewin(fs.readFileSync(fileRef.filePath));
} catch (error) {
return res.status(400).json({ error: `Could not read .intunewin package: ${error.message}` });
}
const auditBase = {
connectionId, deploymentId: deployment.id, actorUserId: req.user.id, actorEmail: req.user.email,
action: 'win32app.content-upload', targetGraphId: deployment.graphAppId,
request: { assetId, setupFile: parsed.setupFile, size: parsed.unencryptedContentSize, sizeEncrypted: parsed.encryptedContentSize }
};
try {
const result = await uploadWin32Content(connection, deployment.graphAppId, parsed);
updateIntuneDeployment(req.params.id, { ...deployment, intunewinFile: fileRef.asset.originalName, status: 'published' }, req.user.id);
if (approval.requestId) markRequestExecuted(approval.requestId);
recordGraphAudit({ ...auditBase, status: 'success', message: `Committed content version ${result.contentVersionId}`, response: result });
res.json({ deployment: loadIntuneDeployment(req.params.id, req.user.id), content: result });
} catch (error) {
recordGraphAudit({ ...auditBase, status: 'failed', message: error.message, response: error.payload || {} });
res.status(400).json({ error: error.message });
}
}
// Reconcile: re-apply the full plan (metadata, assignments, relationships) to the
// tenant to clear drift. Gated + approval-aware + audited as one action.
export async function intuneGraphReconcile(req, res) {
const ctx = resolveWriteContext(req, res);
if (!ctx) return;
const { deployment, connection, connectionId } = ctx;
if (!deployment.graphAppId) return res.status(400).json({ error: 'Publish the app before reconciling.' });
const approval = ensureApproved(ctx, 'reconcile', req, res);
if (!approval.proceed) return;
const profile = deployment.profileId ? loadPsadtProfile(deployment.profileId, req.user.id) : null;
const { payload } = buildWin32LobAppPayload(deployment, profile);
const { assignments } = buildAssignmentsPayload(deployment);
let relationshipsBody = { relationships: [] };
try {
relationshipsBody = buildRelationshipsPayload(deployment.relationships, deployment.graphAppId);
} catch { /* invalid relationships are skipped; drift will still flag them */ }
const auditBase = {
connectionId, deploymentId: deployment.id, actorUserId: req.user.id, actorEmail: req.user.email,
action: 'win32app.reconcile', targetGraphId: deployment.graphAppId, request: { app: payload, assignments, relationships: relationshipsBody.relationships }
};
try {
await upsertWin32App(connection, payload, deployment.graphAppId);
await assignWin32App(connection, deployment.graphAppId, assignments);
await setWin32AppRelationships(connection, deployment.graphAppId, relationshipsBody.relationships);
if (approval.requestId) markRequestExecuted(approval.requestId);
recordGraphAudit({ ...auditBase, status: 'success', message: 'Re-applied plan to tenant' });
res.json({ deployment, reconciled: true });
} catch (error) {
recordGraphAudit({ ...auditBase, status: 'failed', message: error.message, response: error.payload || {} });
res.status(400).json({ error: error.message });
}
}
// Report whether this host can build .intunewin packages (Windows + tool, or an
// external packager hook), so the UI can enable/disable the Build action.
export function intuneBuilderStatus(req, res) {
res.json(builderAvailability());
}
// Build a .intunewin from a source folder using IntuneWinAppUtil (or the
// configured packager) and ingest the result into the Asset Library, linking it
// to the deployment for content upload.
export async function intuneBuild(req, res) {
const deployment = loadIntuneDeployment(req.params.id, req.user.id);
if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' });
const sourceFolder = req.body?.sourceFolder || deployment.sourceFolder;
const setupFile = req.body?.setupFile;
if (!sourceFolder) return res.status(400).json({ error: 'A source folder is required (set it on the deployment or pass sourceFolder).' });
if (!setupFile) return res.status(400).json({ error: 'A setup file (entry installer relative to the source folder) is required.' });
const outputDir = path.join(config.toolsDir, '_build', deployment.id);
try {
const { outputFile } = await buildIntuneWin({ sourceFolder, setupFile, outputDir });
const stats = fs.statSync(outputFile);
const asset = createAssetFromUpload(
{ path: outputFile, originalname: path.basename(outputFile), mimetype: 'application/octet-stream', size: stats.size },
{ name: `${deployment.name} package`, visibility: deployment.visibility, groupId: deployment.groupId, packagePath: path.basename(outputFile) },
req.user.id
);
const updated = updateIntuneDeployment(req.params.id, { ...deployment, intunewinFile: asset.originalName }, req.user.id);
res.status(201).json({ asset, deployment: updated });
} catch (error) {
res.status(400).json({ error: error.message });
}
}
// New version: clone a plan into the next version, linked to the same catalog
// application, ready to build/publish. Does not touch the tenant.
export function intuneNewVersion(req, res) {
const deployment = loadIntuneDeployment(req.params.id, req.user.id);
if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' });
const clone = cloneForNextVersion(deployment, { version: req.body?.version, applicationId: req.body?.applicationId });
res.status(201).json(createIntuneDeployment(clone, req.user.id));
}
// Promote a rollout ring to a new intent (e.g. Broad available -> required).
// Updates the plan; the operator then re-assigns to push it to the tenant.
export function intunePromote(req, res) {
const deployment = loadIntuneDeployment(req.params.id, req.user.id);
if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' });
const ring = req.body?.ring;
const intent = req.body?.intent;
if (!ring || !['available', 'required', 'uninstall', 'exclude'].includes(intent)) {
return res.status(400).json({ error: 'A ring and a valid intent (available/required/uninstall/exclude) are required' });
}
const { assignments, changed } = setRingIntent(deployment.assignments, ring, intent);
if (!changed) return res.status(400).json({ error: `No assignment ring named "${ring}" to promote.` });
const updated = updateIntuneDeployment(req.params.id, { ...deployment, assignments }, req.user.id);
res.json(updated);
}
// Drift check: compare the stored plan against the live tenant app. Read-only,
// so it needs a Graph connection (with secret) but not the write gate.
export async function intuneGraphDrift(req, res) {
const deployment = loadIntuneDeployment(req.params.id, req.user.id);
if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' });
if (!deployment.graphAppId) return res.status(400).json({ error: 'This deployment has not been published to a tenant yet.' });
const connectionId = req.body?.connectionId || req.query?.connectionId || deployment.graphConnectionId;
if (!connectionId) return res.status(400).json({ error: 'A Graph connection is required to check drift' });
const connection = getGraphConnection(connectionId, req.user.id, true);
if (!connection) return res.status(404).json({ error: 'Graph connection not found' });
const profile = deployment.profileId ? loadPsadtProfile(deployment.profileId, req.user.id) : null;
const { payload: expectedApp } = buildWin32LobAppPayload(deployment, profile);
const { assignments: expectedAssignments } = buildAssignmentsPayload(deployment);
let expectedRelationships = [];
try {
expectedRelationships = buildRelationshipsPayload(deployment.relationships, deployment.graphAppId).relationships;
} catch {
expectedRelationships = [];
}
try {
const live = await getWin32AppState(connection, deployment.graphAppId);
const drift = computeDrift({
expectedApp,
liveApp: live.app,
expectedAssignments,
liveAssignments: live.assignments,
expectedRelationships,
liveRelationships: live.relationships
});
res.json({ deployment: { id: deployment.id, name: deployment.name, graphAppId: deployment.graphAppId }, ...drift });
} catch (error) {
res.status(400).json({ error: error.message });
}
}
export function intuneGraphAudit(req, res) {
const deployment = loadIntuneDeployment(req.params.id, req.user.id);
if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' });
res.json(listGraphAudit({ deploymentId: deployment.id }));
}
export function index(req, res) {
res.json(listPsadtProfiles(req.user.id));
}
export function create(req, res) {
const parsed = psadtProfileSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid PSADT profile payload' });
res.status(201).json(createPsadtProfile(parsed.data, req.user.id));
}
export function show(req, res) {
const profile = loadPsadtProfile(req.params.id, req.user.id);
if (!profile) return res.status(404).json({ error: 'PSADT profile not found' });
res.json(profile);
}
export function update(req, res) {
const profile = updatePsadtProfile(req.params.id, req.body, req.user.id);
if (!profile) return res.status(404).json({ error: 'PSADT profile not found' });
res.json(profile);
}
export function destroy(req, res) {
deletePsadtProfile(req.params.id, req.user.id);
res.status(204).end();
}
export function render(req, res) {
const rendered = renderPsadtProfile(req.params.id, req.user.id);
if (!rendered) return res.status(404).json({ error: 'PSADT profile not found' });
res.json(rendered);
}
export function rules(req, res) {
res.json(validationRuleCatalog());
}
export function validate(req, res) {
const parsed = psadtValidationSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid PSADT validation payload' });
let content = parsed.data.content || '';
const context = { platform: parsed.data.platform };
if (parsed.data.scriptId) {
const script = getScript(parsed.data.scriptId, req.user.id);
if (!script) return res.status(404).json({ error: 'Script not found' });
content = script.content || '';
context.scriptId = script.id;
context.scriptName = script.name;
}
if (parsed.data.profileId) {
const rendered = renderPsadtProfile(parsed.data.profileId, req.user.id);
if (!rendered) return res.status(404).json({ error: 'PSADT profile not found' });
content = rendered.script;
context.profileId = rendered.profile.id;
context.profileName = rendered.profile.name;
context.requireAdmin = rendered.profile.requireAdmin;
}
res.json(validatePsadtScript(content, context));
}
export function migrationPlan(req, res) {
const parsed = psadtMigrationSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid PSADT migration payload' });
const { content, script } = resolveMigrationSource(parsed.data, req.user.id);
if (parsed.data.scriptId && !script) return res.status(404).json({ error: 'Script not found' });
res.json({
sourceScript: script ? { id: script.id, name: script.name } : null,
...planPsadtMigration(content, { targetVersion: parsed.data.targetVersion })
});
}
export function migrationApply(req, res) {
const parsed = psadtMigrationSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid PSADT migration payload' });
const { content, script } = resolveMigrationSource(parsed.data, req.user.id);
if (parsed.data.scriptId && !script) return res.status(404).json({ error: 'Script not found' });
const plan = planPsadtMigration(content, { targetVersion: parsed.data.targetVersion });
let migratedScript = null;
if (parsed.data.createScript) {
migratedScript = createScript({
folderId: script?.folderId || null,
name: `${script?.name || 'Migrated PSADT Script'} - ${parsed.data.nameSuffix}`,
description: `Migrated to PSADT ${plan.targetVersion}${script ? ` from ${script.name}` : ''}. Review manual migration findings before production deployment.`,
content: plan.migratedContent,
visibility: script?.visibility || 'personal',
groupId: script?.groupId || null
}, req.user.id);
}
res.json({ ...plan, sourceScript: script ? { id: script.id, name: script.name } : null, migratedScript });
}
function resolveMigrationSource(payload, userId) {
if (payload.scriptId) {
const script = getScript(payload.scriptId, userId);
return { script, content: script?.content || '' };
}
return { script: null, content: payload.content || '' };
}
function summarizeInstallStatuses(statuses = []) {
const summary = { total: statuses.length, installed: 0, failed: 0, pending: 0, notInstalled: 0, notApplicable: 0, unknown: 0, devices: 0, users: 0 };
for (const row of statuses) {
if (row.scope === 'user') summary.users += 1;
else summary.devices += 1;
const key = row.installState || 'unknown';
if (summary[key] == null) summary.unknown += 1;
else summary[key] += 1;
}
return summary;
}