// ========== SHIPDECK DEPLOYS (DC-130) ========== // Source deploys panel: drives the shipdeck CLI via the DashCaddy // /api/v1/deploys bridge proxy. Modal lists deployable repos + deployed // services, shows the journal, and runs deploy/rollback with live output. // // Follows the security-center.js modal pattern (injectModal + button in the // top bar) and the weather-modal visual language. // DC-133: pure repo-option builder. Remote Gitea fields are UNTRUSTED (a // hostile instance controls full_name/description/url), so every // interpolated value must be HTML-escaped before innerHTML. Exposed on // window so status/tests can pin the escaping with hostile payloads. window.__dc133_buildRepoOptions = function (repos, escFn) { var e = escFn || function (s) { return String(s).replace(/[&<>"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; }); }; return '' + (repos || []).map(function (r) { return ''; }).join(''); }; // Three-state token semantics, exposed for VM tests: // anonymous=true -> explicit empty; typed value -> token; neither -> omitted. window.__dc133_requestToken = function (anonymous, typed) { if (anonymous) return ''; var t = String(typed || '').trim(); return t || undefined; }; (function () { injectModal('deploys-modal', `

🚚 Deploys

Loading…
`); const $ = (id) => document.getElementById(id); let openBtn = null; let loadedOnce = false; function esc(s) { return window.escapeHtml ? window.escapeHtml(String(s)) : String(s).replace(/[&<>"']/g, (c) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } async function api(path, opts) { const r = await fetch('/dashcaddy-api/api/v1/deploys' + path, Object.assign({ credentials: 'include' }, opts || {})); let body = null; try { body = await r.json(); } catch (e) { body = { success: false, error: 'non-JSON response' }; } return { status: r.status, body }; } function showOutput(text) { const el = $('dep-output'); el.style.display = 'block'; el.textContent = text || '(no output)'; el.scrollTop = el.scrollHeight; } async function loadServices() { const el = $('dep-services'); const { body } = await api('/services'); if (!body.success) { el.innerHTML = '' + esc(body.error || 'unavailable') + ''; return; } const rows = body.services || []; if (!rows.length) { el.innerHTML = 'No shipdeck deployments on record yet.'; return; } el.innerHTML = '' + '' + '' + rows.map((s) => '' + '' + '' + '' + '' + '' + '' ).join('') + '
ServiceLast actionWhenRelease
' + esc(s.name) + '' + esc(s.last_action) + '' + esc(s.last_time) + '' + esc(s.last_epoch) + ' ' + '
'; } async function loadRepos() { const sel = $('dep-repo-select'); const { body } = await api('/repos'); if (!body.success) { sel.innerHTML = ''; return; } const repos = body.repos || []; sel.innerHTML = repos.length ? repos.map((r) => '').join('') : ''; } async function loadJournal(service) { const el = $('dep-journal'); const qs = service ? '?service=' + encodeURIComponent(service) : ''; const { body } = await api('/journal' + qs); const rows = (body.rows || []); if (!rows.length) { el.innerHTML = 'No journal rows.'; return; } el.innerHTML = '' + '' + '' + rows.map((r) => '' ).join('') + '
TimeServiceActionReleaseDuration
' + esc(r.time) + '' + esc(r.service) + '' + esc(r.action) + '' + esc(r.epoch) + '' + esc(r.duration || '—') + '
'; } async function loadGiteaRepos() { const sel = document.getElementById('dep-gh-gitea'); const hostEl = document.getElementById('dep-gh-gitea-host'); const tokEl = document.getElementById('dep-gh-gitea-token'); const anonEl = document.getElementById('dep-gh-anonymous'); const payload = {}; if (hostEl && hostEl.value.trim()) payload.gitea_url = 'https://' + hostEl.value.trim().replace(/^https?:\/*/, ''); // Distinguish omitted (fleet fallback allowed) from explicit anonymous // (empty token preserved on wire). A non-empty user token wins. if (anonEl && anonEl.checked) payload.token = ''; else { const t = window.__dc133_requestToken(false, tokEl && tokEl.value); if (t !== undefined) payload.token = t; } try { const { status, body } = await api('/gitea-repos', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!body.success || !(body.repos || []).length) { sel.innerHTML = ''; return; } // All remote fields are untrusted (hostile Gitea instance or repo // metadata) — build options through the escaping helper (pinned by // status/tests/deploys-install.test.js). sel.innerHTML = window.__dc133_buildRepoOptions(body.repos, esc); } catch (e) { sel.innerHTML = ''; } } async function runInstall() { const btn = document.getElementById('dep-gh-btn'); const repoUrl = document.getElementById('dep-gh-url').value.trim(); const service = (document.getElementById('dep-gh-service').value.trim() || '').toLowerCase(); const argsRaw = document.getElementById('dep-gh-args').value.trim(); if (!repoUrl || !service) { showOutput('Repo URL and name are required.'); return; } const args = argsRaw ? argsRaw.split(/\s+/) : []; setBusy(true, btn, 'Install'); showOutput('Installing ' + repoUrl + '\nCloning, building, gating and DNS-ing... (~30-60s)'); try { const anonymous = !!(document.getElementById('dep-gh-anonymous') || {}).checked; const typedToken = (document.getElementById('dep-gh-gitea-token') || { value: '' }).value; const requestToken = window.__dc133_requestToken(anonymous, typedToken); const { status, body } = await api('/install', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ repo_url: repoUrl, service, args, token: requestToken }), }); if (!body.success) { showOutput((body.output ? body.output + '\n' : '') + 'FAIL ' + (body.error || ('HTTP ' + status))); setBusy(false, btn, 'Install'); return; } const svc = body.service || {}; const exists = (window.APPS || []).some((a) => a.id === svc.id); if (!exists) { const card = { id: svc.id, name: svc.name || svc.id, url: svc.url, logo: svc.logo, tailscaleOnly: true, isCustom: true }; try { await fetch('/dashcaddy-api/api/v1/services', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(card), }); window.APPS.push(card); if (typeof window.renderApps === 'function') window.renderApps(); if (typeof window.renderGrid === 'function') window.renderGrid(); } catch (e) { /* card registration best-effort */ } } showOutput('INSTALLED ' + (svc.name || svc.id) + ' in ' + (svc.deploy_seconds || '?') + 's' + '\nCard: ' + (svc.url || '') + '\n' + (body.output || '').slice(-800)); loadServices(); } catch (e) { showOutput('install error: ' + e.message); } setBusy(false, btn, 'Install'); } function setBusy(busy, btn, label) { if (!btn) return; btn.disabled = busy; if (label) btn.textContent = busy ? 'Working…' : label; } async function runDeploy() { const btn = $('dep-deploy-btn'); const dir = $('dep-repo-select').value; if (!dir) return; setBusy(true, btn, 'Deploy'); showOutput('Deploying ' + dir + '\nThis can take ~30-60s…'); try { const { body } = await api('/deploy', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dir }), }); showOutput((body.output || body.error || '') + (body.success ? '\n✅ SUCCESS' : '\n❌ FAILED')); loadServices(); } catch (e) { showOutput('deploy error: ' + e.message); } setBusy(false, btn, 'Deploy'); } async function runRollback(service, btn) { if (!confirm('Roll back ' + service + ' to the previous release?')) return; setBusy(true, btn, 'Rollback'); showOutput('Rolling back ' + service + '…'); try { const { body } = await api('/rollback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ service }), }); showOutput((body.output || body.error || '') + (body.success ? '\n✅ ROLLED BACK' : '\n❌ FAILED')); loadServices(); } catch (e) { showOutput('rollback error: ' + e.message); } setBusy(false, btn, 'Rollback'); } async function runStatus(service, btn) { setBusy(true, btn, 'Status'); try { const { body } = await api('/status?service=' + encodeURIComponent(service)); showOutput(body.output || body.error || '(no output)'); } catch (e) { showOutput('status error: ' + e.message); } setBusy(false, btn, 'Status'); } function wire() { openBtn = document.getElementById('deploys-btn'); if (!openBtn) return; openBtn.addEventListener('click', async () => { $('deploys-modal').classList.add('open'); if (!loadedOnce) { loadedOnce = true; loadServices(); loadRepos(); loadJournal(''); loadGiteaRepos(); } }); $('dep-close').addEventListener('click', () => { $('deploys-modal').classList.remove('open'); }); // tab switching document.querySelectorAll('.dep-tab').forEach((tab) => { tab.addEventListener('click', () => { document.querySelectorAll('.dep-tab').forEach((t) => t.classList.remove('active')); tab.classList.add('active'); document.querySelectorAll('#deploys-modal .dep-panel').forEach((p) => { p.style.display = p.dataset.panel === tab.dataset.tab ? 'block' : 'none'; }); }); }); $('dep-deploy-btn').addEventListener('click', runDeploy); $('dep-gh-btn').addEventListener('click', runInstall); ['dep-gh-gitea-host', 'dep-gh-gitea-token'].forEach((id) => { const el = document.getElementById(id); if (el) el.addEventListener('change', () => loadGiteaRepos()); }); document.getElementById('dep-gh-gitea').addEventListener('change', (e) => { const v = e.target.value; if (v) { document.getElementById('dep-gh-url').value = v; const opt = e.target.selectedOptions[0]; const id = opt && opt.dataset ? opt.dataset.id : ''; const svc = document.getElementById('dep-gh-service'); if (id && !svc.value) svc.value = id; } }); $('dep-journal-btn').addEventListener('click', () => loadJournal($('dep-journal-name').value.trim())); $('dep-status-btn').addEventListener('click', () => { const name = $('dep-status-name').value.trim(); if (name) runStatus(name); }); // dynamic buttons (service table) $('dep-services').addEventListener('click', (e) => { const rb = e.target.closest('.dep-rollback'); if (rb) return runRollback(rb.dataset.service, rb); const sb = e.target.closest('.dep-status'); if (sb) return runStatus(sb.dataset.service, sb); }); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', wire); } else { wire(); } })();