Builder v2: local project browser + multi-team ASC keys

Rewrites the builder console to browse local Xcode projects instead of
accepting source uploads or git URLs. Replaces the devices page with a
profiles page that manages ad-hoc provisioning profiles and lists
registered bundle IDs per team.

Adds multi-account support: ASC API keys are now stored in an asc_keys
table keyed by team_id (team_name, key_id, issuer_id, p8_filename). At
build time, the worker reads DEVELOPMENT_TEAM from the Xcode project and
auto-picks the matching key for fastlane sigh + JWT signing. Legacy
single-key settings auto-migrate on first boot.

Fixes storefront IPA parser to handle binary plists produced by Xcode.
Drops the enrollment bridge, device management routes, and direct
ASC API client -- fastlane sigh handles profile lifecycle now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-16 14:43:16 -05:00
parent 8dbe87da2e
commit 491f3a22ba
24 changed files with 4006 additions and 826 deletions

View File

@@ -1,4 +1,8 @@
const $ = (s) => document.querySelector(s);
const esc = (s) => { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; };
let selectedProject = null;
function toast(msg, kind = '') {
const t = $('#toast');
t.textContent = msg;
@@ -6,46 +10,147 @@ function toast(msg, kind = '') {
setTimeout(() => t.classList.remove('show'), 3500);
}
$('#upload-form').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const btn = e.target.querySelector('button[type=submit]');
btn.disabled = true;
btn.textContent = 'Uploading…';
// --- Path bar (breadcrumbs) ---
function renderPathBar(currentPath) {
const bar = $('#path-bar');
const segments = currentPath.split('/').filter(Boolean);
let html = '';
for (let i = 0; i < segments.length; i++) {
const fullPath = '/' + segments.slice(0, i + 1).join('/');
if (i > 0) html += '<span class="path-separator">/</span>';
html += `<span class="path-segment" data-path="${esc(fullPath)}">${esc(segments[i])}</span>`;
}
bar.innerHTML = html;
bar.querySelectorAll('.path-segment').forEach((el) => {
el.addEventListener('click', () => browse(el.dataset.path));
});
}
// --- File list ---
function renderFileList(entries) {
const list = $('#file-list');
if (!entries.length) {
list.innerHTML = '<p style="color:var(--text-muted);padding:8px 0">No Xcode projects or subdirectories found here.</p>';
return;
}
list.innerHTML = entries.map((e) => {
let icon, cls;
if (e.type === 'xcworkspace') {
icon = '\u{1F4E6}';
cls = 'xcode-project';
} else if (e.type === 'xcodeproj') {
icon = '\u{1F528}';
cls = 'xcode-project';
} else {
icon = '\u{1F4C1}';
cls = '';
}
return `<div class="file-entry ${cls}" data-path="${esc(e.path)}" data-type="${e.type}">
<span class="icon">${icon}</span>
<span class="name">${esc(e.name)}</span>
</div>`;
}).join('');
list.querySelectorAll('.file-entry').forEach((el) => {
el.addEventListener('click', () => {
if (el.dataset.type === 'directory') {
browse(el.dataset.path);
} else {
selectProject(el.dataset.path);
}
});
});
}
// --- Browse directory ---
async function browse(dirPath) {
const list = $('#file-list');
list.innerHTML = '<p style="color:var(--text-muted)">Loading...</p>';
const url = dirPath
? `/api/filesystem/browse?path=${encodeURIComponent(dirPath)}`
: '/api/filesystem/browse';
try {
const r = await fetch('/api/build/upload', { method: 'POST', body: fd });
const r = await fetch(url);
if (r.status === 401) { location.href = '/login'; return; }
const data = await r.json();
if (!r.ok) throw new Error(data.error || 'Upload failed');
location.href = `/builds#${data.job_id}`;
if (!r.ok) throw new Error(data.error);
renderPathBar(data.path);
renderFileList(data.entries);
} catch (err) {
list.innerHTML = `<p style="color:var(--danger)">${esc(err.message)}</p>`;
}
}
// --- Select a project ---
async function selectProject(projectPath) {
selectedProject = projectPath;
const config = $('#project-config');
config.style.display = 'block';
$('#selected-project').textContent = projectPath;
const select = $('#scheme-select');
select.disabled = true;
select.innerHTML = '<option>Loading schemes...</option>';
$('#build-btn').disabled = true;
document.querySelectorAll('.file-entry').forEach((el) => {
el.classList.toggle('selected', el.dataset.path === projectPath);
});
try {
const r = await fetch(`/api/filesystem/schemes?projectPath=${encodeURIComponent(projectPath)}`);
const data = await r.json();
if (!r.ok) throw new Error(data.error);
if (!data.schemes.length) {
select.innerHTML = '<option>No schemes found</option>';
return;
}
select.innerHTML = data.schemes.map((s) =>
`<option value="${esc(s)}">${esc(s)}</option>`
).join('');
select.disabled = false;
$('#build-btn').disabled = false;
} catch (err) {
select.innerHTML = `<option>Error: ${err.message}</option>`;
toast(err.message, 'error');
}
}
// --- Build button ---
$('#build-btn').addEventListener('click', async () => {
if (!selectedProject) return;
const scheme = $('#scheme-select').value;
const btn = $('#build-btn');
btn.disabled = true;
btn.textContent = 'Starting build...';
try {
const r = await fetch('/api/build/local', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectPath: selectedProject, scheme }),
});
const data = await r.json();
if (!r.ok) throw new Error(data.error || 'Build failed to start');
location.href = `/#${data.job_id}`;
} catch (err) {
toast(err.message, 'error');
btn.disabled = false;
btn.textContent = 'Queue Build';
btn.textContent = 'Build';
}
});
$('#git-form').addEventListener('submit', async (e) => {
e.preventDefault();
const body = {
url: e.target.url.value.trim(),
branch: e.target.branch.value.trim() || null,
scheme: e.target.scheme.value.trim() || null,
};
const btn = e.target.querySelector('button[type=submit]');
btn.disabled = true;
btn.textContent = 'Cloning…';
try {
const r = await fetch('/api/build/git', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await r.json();
if (!r.ok) throw new Error(data.error || 'Clone failed');
location.href = `/builds#${data.job_id}`;
} catch (err) {
toast(err.message, 'error');
btn.disabled = false;
btn.textContent = 'Queue Build';
}
});
// Start browsing at default path
browse(null);