Phase 4-5: build pipeline + device enrollment

Builder service (Mac mini):
- Build worker: xcodebuild archive + export + fastlane signing + upload to unraid
- /api/build/upload (source archive) and /api/build/git (clone) ingest paths
- SSE-streamed build logs, builds list UI, live status updates
- /api/devices/from-enrollment bridge endpoint (shared-secret auth)

Storefront (unraid):
- /enroll/ public flow: landing page, mobileconfig generator, callback parser
- Forwards extracted UDIDs to the Mac mini builder for ASC registration
- docker-compose.yml now passes BUILDER_URL and BUILDER_SHARED_SECRET

Updated CLAUDE.md with full architecture, deploy flow, and gotchas.
This commit is contained in:
trey
2026-04-11 14:04:32 -05:00
parent e9b6936904
commit 8dbe87da2e
14 changed files with 1203 additions and 44 deletions

107
builder/public/js/builds.js Normal file
View File

@@ -0,0 +1,107 @@
const $ = (s) => document.querySelector(s);
const esc = (s) => { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; };
let currentEventSource = null;
function formatDate(s) {
if (!s) return '—';
return new Date(s + 'Z').toLocaleString();
}
function duration(start, end) {
if (!start) return '—';
const s = new Date(start + 'Z').getTime();
const e = end ? new Date(end + 'Z').getTime() : Date.now();
const secs = Math.max(0, Math.round((e - s) / 1000));
if (secs < 60) return `${secs}s`;
return `${Math.floor(secs / 60)}m ${secs % 60}s`;
}
function statusBadge(status) {
const running = ['preparing', 'signing', 'archiving', 'exporting', 'uploading'].includes(status);
const cls = status === 'succeeded' ? 'succeeded'
: status === 'failed' ? 'failed'
: running ? 'running'
: 'pending';
return `<span class="badge ${cls}">${status}</span>`;
}
async function loadJobs() {
const r = await fetch('/api/builds');
if (r.status === 401) { location.href = '/login'; return; }
const jobs = await r.json();
const container = $('#jobs-container');
if (!jobs.length) {
container.innerHTML = '<div class="card"><p style="color:var(--text-muted)">No builds yet. Start one from <a href="/build" style="color:var(--accent)">New Build</a>.</p></div>';
return;
}
container.innerHTML = `
<table class="table">
<thead>
<tr><th>Status</th><th>Bundle</th><th>Source</th><th>Started</th><th>Duration</th><th></th></tr>
</thead>
<tbody>
${jobs.map(j => `
<tr data-id="${j.id}" style="cursor:pointer">
<td>${statusBadge(j.status)}</td>
<td class="mono">${esc(j.bundle_id) || '<span style="color:var(--text-muted)">—</span>'}</td>
<td class="mono">${esc(j.source_kind)}: ${esc((j.source_ref || '').slice(0, 40))}</td>
<td class="mono">${esc(formatDate(j.started_at))}</td>
<td class="mono">${esc(duration(j.started_at, j.finished_at))}</td>
<td>${j.install_url ? `<a href="${esc(j.install_url)}" class="btn-sm" style="background:var(--accent);color:white;padding:5px 12px;border-radius:14px;text-decoration:none;font-size:12px">Install</a>` : ''}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
container.querySelectorAll('tbody tr').forEach((tr) => {
tr.addEventListener('click', () => openJob(tr.getAttribute('data-id')));
});
}
async function openJob(id) {
location.hash = id;
const r = await fetch(`/api/builds/${id}`);
if (!r.ok) return;
const job = await r.json();
$('#detail').style.display = 'block';
$('#detail-title').textContent = `Job ${id.slice(0, 8)} · ${job.status}`;
$('#detail-meta').innerHTML = `
<div>bundle: ${esc(job.bundle_id || '—')}</div>
<div>scheme: ${esc(job.scheme || '—')}</div>
<div>source: ${esc(job.source_kind)} ${esc(job.source_ref || '')}</div>
<div>started: ${esc(formatDate(job.started_at))} · finished: ${esc(formatDate(job.finished_at))}</div>
${job.install_url ? `<div>install: <a href="${esc(job.install_url)}" style="color:var(--accent)">${esc(job.install_url.slice(0, 80))}…</a></div>` : ''}
${job.error ? `<div style="color:var(--danger)">error: ${esc(job.error)}</div>` : ''}
`;
const logEl = $('#log-viewer');
logEl.textContent = '';
if (currentEventSource) { currentEventSource.close(); currentEventSource = null; }
const es = new EventSource(`/api/builds/${id}/logs`);
currentEventSource = es;
es.onmessage = (ev) => {
logEl.textContent += ev.data + '\n';
logEl.scrollTop = logEl.scrollHeight;
};
es.addEventListener('done', () => {
es.close();
currentEventSource = null;
// Refresh the job list so status pill updates.
loadJobs();
});
es.onerror = () => { es.close(); };
}
loadJobs();
setInterval(loadJobs, 5000);
// If arriving with a hash, open that job.
if (location.hash.length > 1) {
openJob(location.hash.slice(1));
}