Add builder service: scaffold, ASC API, devices UI, fastlane profile manager

Phase 1-3 of the builder subsystem on the Mac mini:
- Express + SQLite + sessions scaffolding, LAN-only service on port 3090
- App Store Connect JWT client (ES256 signing, devices/profiles/bundleIds)
- Device management UI with Apple-side registration
- Fastlane sigh wrapper with profile cache + auto-install to ~/Library/
- launchd plist + deploy script for Mac mini supervision
This commit is contained in:
trey
2026-04-11 13:28:01 -05:00
parent a1e60d390d
commit e9b6936904
19 changed files with 1666 additions and 0 deletions

View File

@@ -0,0 +1,83 @@
const $ = (s) => document.querySelector(s);
const esc = (s) => { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; };
function toast(msg, kind = '') {
const t = $('#toast');
t.textContent = msg;
t.className = 'toast show ' + kind;
setTimeout(() => t.classList.remove('show'), 3500);
}
async function load() {
const res = await fetch('/api/devices');
if (res.status === 401) { location.href = '/login'; return; }
const devices = await res.json();
const container = $('#devices-container');
if (!devices.length) {
container.innerHTML = '<div class="card"><p style="color:var(--text-muted)">No devices registered yet.</p></div>';
return;
}
container.innerHTML = `
<table class="table">
<thead>
<tr><th>Name</th><th>UDID</th><th>Status</th><th>Added</th><th></th></tr>
</thead>
<tbody>
${devices.map(d => `
<tr>
<td>${esc(d.name) || '<span class="mono" style="color:var(--text-muted)">unnamed</span>'}</td>
<td class="mono">${esc(d.udid)}</td>
<td>${d.synced_at
? '<span class="badge synced">Synced</span>'
: '<span class="badge unsynced">Local only</span>'}</td>
<td class="mono">${esc(d.added_at)}</td>
<td><button class="delete-btn" data-udid="${esc(d.udid)}" title="Delete">&times;</button></td>
</tr>
`).join('')}
</tbody>
</table>
`;
container.querySelectorAll('.delete-btn').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm('Remove this device locally? (It will remain in the Apple portal.)')) return;
const udid = btn.getAttribute('data-udid');
const r = await fetch(`/api/devices/${udid}`, { method: 'DELETE' });
if (r.ok) { toast('Removed', 'success'); load(); }
else toast('Delete failed', 'error');
});
});
}
$('#add-form').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const body = {
udid: form.udid.value.trim(),
name: form.name.value.trim(),
};
const btn = form.querySelector('button[type=submit]');
btn.disabled = true;
btn.textContent = 'Registering…';
const r = await fetch('/api/devices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await r.json().catch(() => ({}));
btn.disabled = false;
btn.textContent = 'Add Device';
if (r.ok) {
toast(data.synced ? 'Registered with Apple' : 'Saved locally (ASC not configured)', 'success');
form.reset();
load();
} else {
toast(data.error || 'Register failed', 'error');
}
});
load();

View File

@@ -0,0 +1,74 @@
const $ = (sel) => document.querySelector(sel);
const toast = (msg, kind = '') => {
const t = $('#toast');
t.textContent = msg;
t.className = 'toast show ' + kind;
setTimeout(() => t.classList.remove('show'), 3000);
};
async function load() {
const res = await fetch('/api/settings');
if (res.status === 401) { location.href = '/login'; return; }
const s = await res.json();
$('[name=asc_key_id]').value = s.asc_key_id || '';
$('[name=asc_issuer_id]').value = s.asc_issuer_id || '';
$('[name=unraid_url]').value = s.unraid_url || '';
$('[name=unraid_token]').value = s.unraid_token || '';
$('#p8-status').textContent = s.asc_key_uploaded
? `✓ .p8 uploaded for key ${s.asc_key_id}`
: 'No .p8 uploaded yet';
}
async function saveForm(formEl, keys) {
const data = Object.fromEntries(keys.map(k => [k, formEl.querySelector(`[name=${k}]`)?.value || '']));
const res = await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (res.ok) toast('Saved', 'success');
else toast('Save failed', 'error');
}
$('#asc-form').addEventListener('submit', async (e) => {
e.preventDefault();
await saveForm(e.target, ['asc_key_id', 'asc_issuer_id']);
// Upload .p8 if one was selected
const file = $('#p8-input').files[0];
if (file) {
const fd = new FormData();
fd.append('p8', file);
const res = await fetch('/api/settings/p8', { method: 'POST', body: fd });
if (res.ok) {
toast('.p8 uploaded', 'success');
load();
} else {
const err = await res.json().catch(() => ({}));
toast(err.error || 'Upload failed', 'error');
}
}
});
$('#unraid-form').addEventListener('submit', async (e) => {
e.preventDefault();
await saveForm(e.target, ['unraid_url', 'unraid_token']);
});
$('#test-asc').addEventListener('click', async () => {
const res = await fetch('/api/settings/test-asc', { method: 'POST' });
const data = await res.json();
if (res.ok) toast(`Connected — ${data.device_count} devices in portal`, 'success');
else toast(data.error || 'Connection failed', 'error');
});
$('#test-unraid').addEventListener('click', async () => {
const res = await fetch('/api/settings/test-unraid', { method: 'POST' });
const data = await res.json();
if (res.ok) toast(`Connected to unraid — ${data.app_count} apps`, 'success');
else toast(data.error || 'Connection failed', 'error');
});
load();