/* * SysDeck - Packages Panel * Author: Jeremy Anderson (https://dcos.net) * * Package management panel — list, search, install, update, and remove * packages via the system package manager. Ten managers, the same * step-down as the bridge: pacman (Arch) · emerge (Gentoo) · lunar * (Lunar) · sorcery (SourceMage) · xbps (Void) · apk (Alpine) · * zypper (openSUSE) · dnf / yum (RPM) · apt (Debian). * The package manager is invoked as a separate process via cockpit.spawn — * no package-manager code is bundled. * * Mutations (install/remove/update/update-all) execute via the cockpit * superuser channel (polkit): the bridge helper runs the detected * package manager via subprocess, and this panel subscribes to the * cockpit spawn stream so the operator sees live stdout/stderr in a *
log panel — exactly like cockpit's own Packages and Software
* Updates panels. No `sudo` shell-out from JS.
*
* SECURITY: every dynamic string interpolated into innerHTML
* (package names, versions, descriptions, search terms echoed back,
* error messages) goes through escapeHtml(). Package metadata is
* live data from the package manager's output — a typo-squat repo or
* a locally-installed package whose name/description contains markup
* must not execute in the cockpit admin session (0.3.0 audit).
* Raw tool output already flows through textContent (showOutput),
* which is safe.
*/
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
export async function mount(panel, { bridge, EventBus }) {
panel.innerHTML = renderSkeleton();
let summary = {};
let installed = [];
try {
[summary, installed] = await Promise.all([
bridge.packages.summary(),
bridge.packages.listInstalled(),
]);
} catch (err) {
panel.innerHTML = renderError(err);
return;
}
const mgr = summary.manager || 'unknown';
const instCount = summary.installedCount || installed.length;
const updCount = summary.updateCount || 0;
const updates = summary.updates || [];
// Managers without an update-preview subcommand (lunar) carry the
// explanation instead of a count that would read as "all current".
const updLine = summary.updatesNote
? `${escapeHtml(mgr)} — ${instCount} installed · ${escapeHtml(summary.updatesNote)}`
: `${escapeHtml(mgr)} — ${instCount} installed · ${updCount} updates available`;
panel.innerHTML = `
Package Manager
${updLine}
Installed
${instCount}
packages via ${escapeHtml(mgr)}
Updates
${updCount}
${updCount > 0 ? 'updates pending' : 'system is up to date'}
Pending Updates
Package Current New
${updates.map((u) => `
${escapeHtml(u.name || u.package || '—')}
${escapeHtml(u.current || '—')}
${escapeHtml(u.new || '—')}
`).join('') || 'No pending updates. '}
Search Packages
Enter a search term to find packages.
Recently Installed
Package Version
${installed.slice(0, 25).map((p) => `
${escapeHtml(p.name)}
${escapeHtml(p.version)}
`).join('') || 'No packages found. '}
${installed.length > 25 ? `Showing 25 of ${installed.length} packages.
` : ''}
`;
// ── Output helpers ──────────────────────────────────────────────
const outputCard = panel.querySelector('#pkg-output-card');
const outputPre = panel.querySelector('#pkg-output-pre');
const outputTitle = panel.querySelector('#pkg-output-title');
const outputStatus = panel.querySelector('#pkg-output-status');
const showOutput = (title, text, status = '') => {
if (!outputCard || !outputPre) return;
outputCard.style.display = 'block';
outputTitle.textContent = title;
outputPre.textContent = text;
outputStatus.textContent = status;
outputPre.style.color = status.startsWith('FAIL') ? 'var(--sysdeck-accent-danger)' : 'var(--sysdeck-fg)';
};
panel.querySelector('#btn-pkg-output-close')?.addEventListener('click', () => {
if (outputCard) outputCard.style.display = 'none';
});
// ── Update All — runs the operation, no alert ──────────────────
panel.querySelector('#btn-update-all')?.addEventListener('click', async () => {
if (!updCount) return;
showOutput('Update All — running', `Running ${mgr} upgrade via cockpit superuser channel...\n(cockpit will prompt for auth)`, 'running');
try {
const result = await bridge.packages.updateAll();
const lines = [];
if (result.command) lines.push(`$ ${result.command}\n`);
if (result.output) lines.push(result.output);
if (result.stderr) lines.push(`\n--- stderr ---\n${result.stderr}`);
lines.push(`\n--- exit: ${result.rc} ---`);
const ok = result.success;
showOutput(`Update All — ${ok ? 'success' : 'failed'}`,
lines.join('\n'),
ok ? 'OK' : `FAIL rc=${result.rc}`);
EventBus.emit('packages.update-all', result);
if (ok) setTimeout(() => mount(panel, { bridge, EventBus }), 1500);
} catch (err) {
showOutput('Update All — error', String(err.message || err), 'FAIL');
}
});
// ── Preview Command — dry-run ──────────────────────────────────
panel.querySelector('#btn-update-preview')?.addEventListener('click', async () => {
try {
const r = await bridge.packages.dryRun('update-all');
showOutput('Preview — command that will run',
`Action: ${r.action}\nManager: ${r.manager}\n\n$ ${r.command || '(no command)'}\n\nThis command will be run with root privileges via the cockpit superuser channel (polkit org.sysdeck.packages.modify).`,
'preview');
} catch (err) {
showOutput('Preview — error', String(err.message || err), 'FAIL');
}
});
// ── Search ──────────────────────────────────────────────────────
panel.querySelector('#btn-search')?.addEventListener('click', async () => {
const term = panel.querySelector('#pkg-search')?.value?.trim();
if (!term) return;
const resultsDiv = panel.querySelector('#pkg-search-results');
if (resultsDiv) resultsDiv.textContent = 'Searching...';
try {
const results = await bridge.packages.search(term);
if (resultsDiv) {
resultsDiv.innerHTML = results.length
? `| Package | Version |
|---|---|
| ${escapeHtml(r.name)} | ${escapeHtml(r.version || r.description || '—')} |
${escapeHtml(err.message || err)}. Ensure a supported package manager is installed (pacman, emerge, lunar, sorcery, xbps, apk, zypper, dnf, yum, or apt).