/* * SysDeck - Packages Panel (v0.0.31) * Author: Jeremy Anderson (https://dcos.net) * * Package management panel — list, search, install, update, and remove * packages via the system package manager (pacman / dnf / apt). * The package manager is invoked as a separate process via cockpit.spawn — * no package-manager code is bundled. * * v0.0.31 REWRITE — UPDATE NEEDS SUDO, FIXED THE COCKPIT WAY. * v0.0.30 packages.js Update All button called bridge.packages.updateAll() * which returned only the command string that *would* be run. The panel * showed `alert("Run this command with superuser privileges.")` and the * operator had to copy the command, open a terminal, sudo, paste, run. * That defeated the purpose of having a panel. * * v0.0.31 makes install/remove/update/update-all actually execute via * the cockpit superuser channel (polkit). The bridge helper runs the * detected package manager via subprocess, and the JS 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.
*
* v0.1.4 SECURITY: every dynamic string interpolated into innerHTML
* (package names, versions, descriptions, search terms echoed back,
* error messages) now goes through escapeHtml(). Package metadata is
* live data from pacman/dnf/apt output — a typo-squat repo or a
* locally-installed package whose name/description contains markup
* used to 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 || [];
panel.innerHTML = `
Package Manager
${escapeHtml(mgr)} — ${instCount} installed · ${updCount} updates available
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 pacman, dnf, or apt is installed.