// 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 (ab). 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/). 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}"`); }