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

@@ -29,7 +29,8 @@ The split exists because `xcodebuild` needs macOS and the Mac mini is the only m
- **Data**: `/Users/m4mini/AppStoreBuilder/data/` (SQLite + ASC keys + source archives + build artifacts + logs)
- **Process supervision**: launchd — `~/Library/LaunchAgents/com.88oak.appstorebuilder.plist` (KeepAlive, RunAtLoad)
- **Env vars** (in `builder/.env`, loaded non-destructively by `src/server.js`):
- `ADMIN_PASSWORD`, `SESSION_SECRET`, `DATA_DIR`, `PORT`, `BUILDER_SHARED_SECRET`
- `ADMIN_PASSWORD`, `SESSION_SECRET`, `DATA_DIR`, `PORT`
- **ASC API keys** live in the `asc_keys` table (one row per Apple Developer team), not in env/settings. Columns: `team_id`, `team_name`, `key_id`, `issuer_id`, `p8_filename`. Managed at `/settings` → "Developer Accounts". `.p8` files stored at `$DATA_DIR/asc/<key_id>.p8` (0600). At build time, the worker reads `DEVELOPMENT_TEAM` from `xcodebuild -showBuildSettings` and looks up the matching key.
**Important**: The builder code must NOT live under `~/Desktop/` when running via launchd. macOS TCC blocks launchd-spawned processes from reading Desktop, which causes the Node process to hang on `__getcwd` during startup. That's why we copy to `/Users/m4mini/AppStoreBuilder/app/` via the deploy script instead of pointing launchd directly at the git checkout in `~/Desktop/code/ios-appstore/builder/`.

View File

@@ -443,6 +443,37 @@ input:focus, select:focus { border-color: var(--accent); }
.badge.failed { background: rgba(255,59,48,0.15); color: var(--danger); }
.badge.synced { background: rgba(48,209,88,0.15); color: var(--success); }
.badge.unsynced { background: rgba(255,149,0,0.15); color: #ff9500; }
.badge-valid { background: rgba(48,209,88,0.15); color: var(--success); display:inline-block; padding:3px 10px; border-radius:10px; font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:0.03em; }
.badge-expiring { background: rgba(255,149,0,0.15); color: #ff9500; display:inline-block; padding:3px 10px; border-radius:10px; font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:0.03em; }
.badge-expired { background: rgba(255,59,48,0.15); color: var(--danger); display:inline-block; padding:3px 10px; border-radius:10px; font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:0.03em; }
/* Data tables (settings / profiles) */
.data-table { width: 100%; border-collapse: collapse; }
.data-table th, .data-table td {
text-align: left;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
font-size: 13px;
vertical-align: middle;
}
.data-table th {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.data-table tr:last-child td { border-bottom: none; }
.data-table code { font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 12px; color: var(--text-muted); }
.action-cell { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
.btn-xs { padding: 4px 10px; font-size: 12px; border-radius: 6px; cursor: pointer; border: 1px solid var(--border); }
.btn-xs.btn-secondary { background: transparent; color: var(--text); }
.btn-xs.btn-secondary:hover { background: var(--surface-hover); }
.btn-danger { background: transparent; color: var(--danger); border: 1px solid var(--danger); padding: 4px 10px; font-size: 12px; border-radius: 6px; cursor: pointer; }
.btn-danger:hover { background: rgba(255,59,48,0.1); }
.upload-label { display: inline-flex; align-items: center; cursor: pointer; }
/* Tables */
.table {
@@ -495,6 +526,20 @@ input:focus, select:focus { border-color: var(--accent); }
.toast.success { border-color: var(--success); }
.toast.error { border-color: var(--danger); }
/* Filesystem browser */
.path-bar { display: flex; gap: 4px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; padding-bottom: 12px; border-bottom: 1px solid var(--border); }
.path-segment { color: var(--accent); cursor: pointer; font-size: 14px; font-family: ui-monospace, 'SF Mono', Menlo, monospace; }
.path-segment:hover { text-decoration: underline; }
.path-separator { color: var(--text-muted); font-size: 12px; user-select: none; }
.file-list { display: flex; flex-direction: column; }
.file-entry { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 8px; cursor: pointer; transition: background 0.1s; }
.file-entry:hover { background: var(--surface-hover); }
.file-entry.selected { background: rgba(0,122,255,0.1); border: 1px solid rgba(0,122,255,0.3); }
.file-entry .icon { width: 24px; text-align: center; font-size: 16px; flex-shrink: 0; }
.file-entry .name { font-size: 14px; }
.file-entry.xcode-project .name { color: var(--accent); font-weight: 600; }
/* Log viewer */
.log-viewer {
background: #000;

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);

View File

@@ -40,14 +40,14 @@ async function loadJobs() {
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>
<tr><th>Status</th><th>Bundle</th><th>Project</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((j.source_ref || '').replace(/\.(xcodeproj|xcworkspace)$/, ''))}</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>
@@ -72,7 +72,7 @@ async function openJob(id) {
$('#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>project: ${esc(job.source_ref || job.project_path || '--')}</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>` : ''}

View File

@@ -1,83 +0,0 @@
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,210 @@
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);
}
function formatDate(s) {
if (!s) return '--';
return new Date(s).toLocaleDateString();
}
function statusBadge(status) {
const cls = status === 'valid' ? 'succeeded'
: status === 'expiring' ? 'pending'
: status === 'expired' ? 'failed'
: 'pending';
return `<span class="badge ${cls}">${status}</span>`;
}
function methodBadge(method) {
if (method === 'ad-hoc') return '<span class="badge running">ad-hoc</span>';
if (method === 'development') return '<span class="badge pending">dev</span>';
if (method === 'app-store') return '<span class="badge succeeded">app-store</span>';
if (method === 'enterprise') return '<span class="badge succeeded">enterprise</span>';
return `<span class="badge">${method || 'unknown'}</span>`;
}
// --- Installed profiles ---
let installedProfiles = [];
async function loadProfiles() {
const r = await fetch('/api/profiles');
if (r.status === 401) { location.href = '/login'; return; }
installedProfiles = await r.json();
const container = $('#profiles-container');
if (!installedProfiles.length) {
container.innerHTML = '<div class="card"><p style="color:var(--text-muted)">No provisioning profiles installed. Generate one from the bundle IDs above.</p></div>';
return;
}
// Only show ad-hoc profiles
const adhoc = installedProfiles.filter(p => p.method === 'ad-hoc');
if (!adhoc.length) {
container.innerHTML = '<div class="card"><p style="color:var(--text-muted)">No ad-hoc profiles found. Other profile types are installed but not shown.</p></div>';
return;
}
container.innerHTML = `
<table class="table">
<thead>
<tr>
<th>Bundle ID</th>
<th>Name</th>
<th>Devices</th>
<th>Expires</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
${adhoc.map(p => `
<tr>
<td class="mono">${esc(p.bundleIdentifier || '--')}</td>
<td style="font-size:13px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(p.name)}">${esc(p.name || '--')}</td>
<td class="mono">${p.deviceCount ?? '--'}</td>
<td class="mono">${esc(formatDate(p.expiresAt))}</td>
<td>${statusBadge(p.status)}</td>
<td style="white-space:nowrap">
${p.bundleIdentifier ? `<button class="btn-sm btn-secondary regen-btn" data-bundle="${esc(p.bundleIdentifier)}" style="margin-right:4px">Regenerate</button>` : ''}
<button class="btn-sm btn-danger delete-btn" data-uuid="${esc(p.uuid)}">Delete</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
bindProfileActions(container);
}
function bindProfileActions(container) {
container.querySelectorAll('.delete-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
if (!confirm('Delete this profile?')) return;
btn.disabled = true;
btn.textContent = '...';
try {
const r = await fetch(`/api/profiles/${btn.dataset.uuid}`, { method: 'DELETE' });
if (!r.ok) throw new Error((await r.json()).error || 'Delete failed');
toast('Profile deleted', 'success');
loadProfiles();
loadBundleIds();
} catch (err) {
toast(err.message, 'error');
btn.disabled = false;
btn.textContent = 'Delete';
}
});
});
container.querySelectorAll('.regen-btn').forEach((btn) => {
btn.addEventListener('click', () => generateProfile(btn.dataset.bundle, null, btn));
});
}
async function generateProfile(bundleId, teamId, btn) {
if (!teamId) {
// Regenerate button on the installed-profiles table doesn't know the team yet — look it up
const installed = installedProfiles.find(p => p.bundleIdentifier === bundleId);
teamId = installed?.teamId;
}
if (!teamId) {
toast('No team ID available for this bundle. Generate from the ASC bundle list above.', 'error');
return;
}
const origText = btn.textContent;
btn.disabled = true;
btn.textContent = 'Generating...';
try {
const r = await fetch('/api/profiles/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bundleId, teamId }),
});
if (!r.ok) throw new Error((await r.json()).error || 'Generation failed');
toast(`Profile generated for ${bundleId}`, 'success');
loadProfiles();
loadBundleIds();
} catch (err) {
toast(err.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = origText;
}
}
// --- Bundle IDs from ASC ---
async function loadBundleIds() {
const container = $('#bundle-ids-container');
try {
const r = await fetch('/api/bundle-ids');
if (r.status === 401) { location.href = '/login'; return; }
const groups = await r.json();
if (groups.error) throw new Error(groups.error);
if (!Array.isArray(groups) || !groups.length) {
container.innerHTML = '<div class="card"><p style="color:var(--text-muted)">No developer accounts configured. Add one in Settings.</p></div>';
return;
}
const installedBundles = new Set(
installedProfiles
.filter(p => p.method === 'ad-hoc')
.map(p => p.bundleIdentifier)
);
container.innerHTML = groups.map(g => {
if (g.error) {
return `<div class="card" style="margin-bottom:16px">
<h3 style="margin-bottom:8px">${esc(g.teamName)} <span class="mono" style="color:var(--text-muted);font-size:12px">${esc(g.teamId)}</span></h3>
<p style="color:var(--danger);font-size:13px">${esc(g.error)}</p>
</div>`;
}
if (!g.bundleIds.length) {
return `<div class="card" style="margin-bottom:16px">
<h3 style="margin-bottom:8px">${esc(g.teamName)} <span class="mono" style="color:var(--text-muted);font-size:12px">${esc(g.teamId)}</span></h3>
<p style="color:var(--text-muted);font-size:13px">No bundle IDs registered.</p>
</div>`;
}
return `<div class="card" style="padding:0;overflow:hidden;margin-bottom:16px">
<div style="padding:12px 20px;border-bottom:1px solid var(--border);background:var(--bg)">
<h3 style="font-size:14px">${esc(g.teamName)} <span class="mono" style="color:var(--text-muted);font-size:12px;margin-left:6px">${esc(g.teamId)}</span></h3>
</div>
${g.bundleIds.map(b => {
const hasProfile = installedBundles.has(b.identifier);
return `<div class="bundle-id-row" style="display:flex;align-items:center;justify-content:space-between;padding:12px 20px;border-bottom:1px solid var(--border)">
<div>
<span class="mono" style="font-size:14px">${esc(b.identifier)}</span>
<span style="color:var(--text-muted);font-size:12px;margin-left:8px">${esc(b.name)}</span>
<span style="color:var(--text-muted);font-size:11px;margin-left:6px">${esc(b.platform)}</span>
</div>
<div style="display:flex;align-items:center;gap:8px">
${hasProfile
? '<span class="badge succeeded">has profile</span>'
: `<button class="btn-sm gen-btn" data-bundle="${esc(b.identifier)}" data-team="${esc(g.teamId)}">Generate Ad-Hoc</button>`
}
</div>
</div>`;
}).join('')}
</div>`;
}).join('');
container.querySelectorAll('.gen-btn').forEach((btn) => {
btn.addEventListener('click', () => generateProfile(btn.dataset.bundle, btn.dataset.team, btn));
});
} catch (err) {
container.innerHTML = `<div class="card"><p style="color:var(--danger)">${esc(err.message)}</p><p style="color:var(--text-muted);font-size:13px;margin-top:8px">Configure at least one Developer Account in Settings to fetch bundle IDs.</p></div>`;
}
}
// Load both in parallel
loadProfiles().then(() => loadBundleIds());

View File

@@ -6,23 +6,131 @@ const toast = (msg, kind = '') => {
setTimeout(() => t.classList.remove('show'), 3000);
};
async function load() {
const escapeHtml = (str) => (str || '').replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
async function loadStorefront() {
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 || '']));
async function loadKeys() {
const res = await fetch('/api/asc-keys');
if (res.status === 401) { location.href = '/login'; return; }
const keys = await res.json();
const container = $('#asc-keys-table');
if (!keys.length) {
container.innerHTML = '<p style="color:var(--text-muted);margin:0">No developer accounts configured yet.</p>';
return;
}
container.innerHTML = `
<table class="data-table">
<thead>
<tr>
<th>Team Name</th>
<th>Team ID</th>
<th>Key ID</th>
<th>Issuer ID</th>
<th>.p8</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${keys.map((k) => `
<tr data-team-id="${escapeHtml(k.team_id)}">
<td>${escapeHtml(k.team_name || '')}</td>
<td><code>${escapeHtml(k.team_id)}</code></td>
<td><code>${escapeHtml(k.key_id)}</code></td>
<td><code style="font-size:11px">${escapeHtml(k.issuer_id)}</code></td>
<td>${k.p8_uploaded ? '<span class="badge badge-valid">uploaded</span>' : '<span class="badge badge-expired">missing</span>'}</td>
<td class="action-cell">
<label class="btn-secondary btn-xs upload-label">
<input type="file" accept=".p8" style="display:none" class="p8-input">
Upload .p8
</label>
<button class="btn-secondary btn-xs test-btn">Test</button>
<button class="btn-danger btn-xs delete-btn">Delete</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
// Wire row actions
container.querySelectorAll('tr[data-team-id]').forEach((row) => {
const teamId = row.dataset.teamId;
row.querySelector('.p8-input').addEventListener('change', (e) => uploadP8(teamId, e.target.files[0]));
row.querySelector('.test-btn').addEventListener('click', () => testKey(teamId));
row.querySelector('.delete-btn').addEventListener('click', () => deleteKey(teamId));
});
}
async function uploadP8(teamId, file) {
if (!file) return;
const fd = new FormData();
fd.append('p8', file);
const res = await fetch(`/api/asc-keys/${encodeURIComponent(teamId)}/p8`, { method: 'POST', body: fd });
const data = await res.json().catch(() => ({}));
if (res.ok) {
toast('.p8 uploaded', 'success');
loadKeys();
} else {
toast(data.error || 'Upload failed', 'error');
}
}
async function testKey(teamId) {
toast('Testing...', '');
const res = await fetch(`/api/asc-keys/${encodeURIComponent(teamId)}/test`, { method: 'POST' });
const data = await res.json().catch(() => ({}));
if (res.ok) toast(`Team ${teamId} authenticated OK`, 'success');
else toast(data.error || 'Test failed', 'error');
}
async function deleteKey(teamId) {
if (!confirm(`Delete ASC key for team ${teamId}? The .p8 file will be removed from disk.`)) return;
const res = await fetch(`/api/asc-keys/${encodeURIComponent(teamId)}`, { method: 'DELETE' });
const data = await res.json().catch(() => ({}));
if (res.ok) {
toast('Deleted', 'success');
loadKeys();
} else {
toast(data.error || 'Delete failed', 'error');
}
}
$('#add-key-form').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const payload = Object.fromEntries(fd.entries());
const res = await fetch('/api/asc-keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (res.ok) {
toast('Saved', 'success');
e.target.reset();
loadKeys();
} else {
toast(data.error || 'Save failed', 'error');
}
});
$('#storefront-form').addEventListener('submit', async (e) => {
e.preventDefault();
const data = {
unraid_url: e.target.querySelector('[name=unraid_url]').value,
unraid_token: e.target.querySelector('[name=unraid_token]').value,
};
const res = await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -30,45 +138,14 @@ async function saveForm(formEl, keys) {
});
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' });
$('#test-storefront').addEventListener('click', async () => {
const res = await fetch('/api/settings/test-storefront', { method: 'POST' });
const data = await res.json();
if (res.ok) toast(`Connected ${data.device_count} devices in portal`, 'success');
if (res.ok) toast(`Connected -- ${data.app_count} apps on storefront`, '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();
loadStorefront();
loadKeys();

View File

@@ -1,141 +0,0 @@
// App Store Connect API client.
// Authenticates with ES256 JWTs signed by the user's .p8 key.
// Docs: https://developer.apple.com/documentation/appstoreconnectapi
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { getSetting, DATA_DIR } = require('./db');
const API_BASE = 'https://api.appstoreconnect.apple.com';
const AUDIENCE = 'appstoreconnect-v1';
const TTL_SECONDS = 15 * 60; // Apple allows up to 20 min
let cachedJwt = null;
let cachedExpiry = 0;
function b64url(buf) {
return Buffer.from(buf)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
function loadKey() {
const keyId = getSetting('asc_key_id');
if (!keyId) throw new Error('ASC key ID not configured (Settings page)');
const keyPath = path.join(DATA_DIR, 'asc', `${keyId}.p8`);
if (!fs.existsSync(keyPath)) throw new Error('.p8 file not uploaded');
return { keyId, keyPem: fs.readFileSync(keyPath, 'utf8') };
}
function signJwt() {
// Return a cached token if still fresh (>60s of life left).
const now = Math.floor(Date.now() / 1000);
if (cachedJwt && cachedExpiry - now > 60) return cachedJwt;
const issuerId = getSetting('asc_issuer_id');
if (!issuerId) throw new Error('ASC Issuer ID not configured (Settings page)');
const { keyId, keyPem } = loadKey();
const header = { alg: 'ES256', kid: keyId, typ: 'JWT' };
const payload = {
iss: issuerId,
iat: now,
exp: now + TTL_SECONDS,
aud: AUDIENCE,
};
const headerB64 = b64url(JSON.stringify(header));
const payloadB64 = b64url(JSON.stringify(payload));
const signingInput = `${headerB64}.${payloadB64}`;
const signer = crypto.createSign('SHA256');
signer.update(signingInput);
signer.end();
// Apple's .p8 files are PKCS8 EC keys. Node signs them as DER by default;
// we need the raw IEEE P1363 r||s form for JWS.
const derSig = signer.sign({ key: keyPem, dsaEncoding: 'ieee-p1363' });
const sigB64 = b64url(derSig);
cachedJwt = `${signingInput}.${sigB64}`;
cachedExpiry = now + TTL_SECONDS;
return cachedJwt;
}
async function ascFetch(pathAndQuery, init = {}) {
const token = signJwt();
const url = `${API_BASE}${pathAndQuery}`;
const res = await fetch(url, {
...init,
headers: {
...(init.headers || {}),
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
const text = await res.text();
let body = null;
if (text) {
try { body = JSON.parse(text); }
catch { body = { raw: text }; }
}
if (!res.ok) {
const err = body?.errors?.[0];
const msg = err
? `${err.title || 'ASC error'}: ${err.detail || err.code || ''}`
: `ASC request failed (${res.status})`;
const e = new Error(msg);
e.status = res.status;
e.body = body;
throw e;
}
return body;
}
// --- Public API ---
async function listDevices() {
// ASC paginates; 200 is the max per page. For a personal store, one page is plenty.
const body = await ascFetch('/v1/devices?limit=200');
return body.data || [];
}
async function registerDevice({ udid, name, platform = 'IOS' }) {
const body = await ascFetch('/v1/devices', {
method: 'POST',
body: JSON.stringify({
data: {
type: 'devices',
attributes: { name: name || udid.slice(0, 8), udid, platform },
},
}),
});
return body.data;
}
async function listBundleIds(identifier) {
const q = identifier ? `?filter[identifier]=${encodeURIComponent(identifier)}` : '';
const body = await ascFetch(`/v1/bundleIds${q}`);
return body.data || [];
}
async function listProfiles() {
const body = await ascFetch('/v1/profiles?limit=200');
return body.data || [];
}
async function deleteProfile(profileId) {
await ascFetch(`/v1/profiles/${profileId}`, { method: 'DELETE' });
}
module.exports = {
signJwt,
listDevices,
registerDevice,
listBundleIds,
listProfiles,
deleteProfile,
};

View File

@@ -1,5 +1,4 @@
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
const BUILDER_SHARED_SECRET = process.env.BUILDER_SHARED_SECRET;
// Session auth for the browser UI
function requireLogin(req, res, next) {
@@ -10,18 +9,8 @@ function requireLogin(req, res, next) {
res.redirect('/login');
}
// Shared-secret auth for enrollment callbacks coming from unraid
function requireBuilderSecret(req, res, next) {
const header = req.headers['authorization'] || '';
const match = header.match(/^Bearer\s+(.+)$/);
if (!match || !BUILDER_SHARED_SECRET || match[1] !== BUILDER_SHARED_SECRET) {
return res.status(401).json({ error: 'Invalid shared secret' });
}
next();
}
function validatePassword(password) {
return password && password === ADMIN_PASSWORD;
}
module.exports = { requireLogin, requireBuilderSecret, validatePassword };
module.exports = { requireLogin, validatePassword };

View File

@@ -1,71 +1,24 @@
// Build pipeline HTTP routes.
// Build pipeline + filesystem browser HTTP routes.
// Attached to the main Express app in server.js via `register(app)`.
const path = require('path');
const fs = require('fs');
const os = require('os');
const { spawn } = require('child_process');
const multer = require('multer');
const { v4: uuidv4 } = require('uuid');
const { db, DATA_DIR } = require('./db');
const buildWorker = require('./build-worker');
const SOURCE_DIR = path.join(DATA_DIR, 'source');
const LOGS_DIR = path.join(DATA_DIR, 'builds');
const TMP_DIR = path.join(DATA_DIR, 'tmp');
fs.mkdirSync(LOGS_DIR, { recursive: true });
[SOURCE_DIR, LOGS_DIR, TMP_DIR].forEach((d) => fs.mkdirSync(d, { recursive: true }));
const DEFAULT_BROWSE_ROOT = path.join(os.homedir(), 'Desktop', 'code');
const archiveUpload = multer({
dest: TMP_DIR,
limits: { fileSize: 500 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
const name = file.originalname.toLowerCase();
if (name.endsWith('.zip') || name.endsWith('.tar.gz') || name.endsWith('.tgz')) {
return cb(null, true);
}
cb(new Error('Only .zip, .tar.gz, or .tgz archives'));
},
});
function extractArchive(archivePath, destDir) {
return new Promise((resolve, reject) => {
fs.mkdirSync(destDir, { recursive: true });
const lower = archivePath.toLowerCase();
let cmd, args;
if (lower.endsWith('.zip')) {
cmd = '/usr/bin/unzip';
args = ['-q', archivePath, '-d', destDir];
} else {
cmd = '/usr/bin/tar';
args = ['-xzf', archivePath, '-C', destDir];
}
const child = spawn(cmd, args);
let stderr = '';
child.stderr.on('data', (c) => { stderr += c.toString(); });
child.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`${cmd} exited ${code}: ${stderr}`));
});
child.on('error', reject);
});
}
function cloneGitRepo({ url, branch, destDir, logPath }) {
return new Promise((resolve, reject) => {
fs.mkdirSync(destDir, { recursive: true });
const args = ['clone', '--depth', '1'];
if (branch) args.push('--branch', branch);
args.push(url, destDir);
fs.appendFileSync(logPath, `$ git ${args.join(' ')}\n`);
const child = spawn('/usr/bin/git', args);
child.stdout.on('data', (c) => fs.appendFileSync(logPath, c));
child.stderr.on('data', (c) => fs.appendFileSync(logPath, c));
child.on('close', (code) => code === 0 ? resolve() : reject(new Error(`git clone failed (${code})`)));
child.on('error', reject);
});
}
// Directories to skip when browsing
const SKIP_DIRS = new Set([
'node_modules', 'Pods', 'build', 'DerivedData',
'.build', '.git', '__MACOSX', 'Carthage',
]);
function register(app, { requireLogin }) {
// --- Pages ---
@@ -76,60 +29,116 @@ function register(app, { requireLogin }) {
res.sendFile(path.join(__dirname, '..', 'views', 'builds.html'));
});
// --- Upload a source archive ---
app.post('/api/build/upload', requireLogin, archiveUpload.single('source'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No source file provided' });
// --- Filesystem browser ---
app.get('/api/filesystem/browse', requireLogin, (req, res) => {
const targetPath = req.query.path || DEFAULT_BROWSE_ROOT;
// Block path traversal
if (targetPath.includes('..')) {
return res.status(400).json({ error: 'Path traversal not allowed' });
}
if (!fs.existsSync(targetPath)) {
return res.status(404).json({ error: 'Path not found' });
}
const stat = fs.statSync(targetPath);
if (!stat.isDirectory()) {
return res.status(400).json({ error: 'Path is not a directory' });
}
let entries;
try {
const jobId = uuidv4();
const sourceDir = path.join(SOURCE_DIR, jobId);
await extractArchive(req.file.path, sourceDir);
fs.unlinkSync(req.file.path);
const scheme = (req.body.scheme || '').trim() || null;
db.prepare(`
INSERT INTO build_jobs (id, source_kind, source_ref, scheme, status)
VALUES (?, 'upload', ?, ?, 'pending')
`).run(jobId, req.file.originalname, scheme);
buildWorker.kick();
res.json({ success: true, job_id: jobId });
entries = fs.readdirSync(targetPath, { withFileTypes: true });
} catch (err) {
return res.status(403).json({ error: `Cannot read directory: ${err.message}` });
}
const result = [];
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
if (SKIP_DIRS.has(entry.name)) continue;
if (entry.isDirectory()) {
if (entry.name.endsWith('.xcodeproj')) {
result.push({ name: entry.name, type: 'xcodeproj', path: path.join(targetPath, entry.name) });
} else if (entry.name.endsWith('.xcworkspace')) {
result.push({ name: entry.name, type: 'xcworkspace', path: path.join(targetPath, entry.name) });
} else {
result.push({ name: entry.name, type: 'directory', path: path.join(targetPath, entry.name) });
}
}
}
// Sort: Xcode projects first, then directories alphabetically
result.sort((a, b) => {
const aIsXcode = a.type === 'xcodeproj' || a.type === 'xcworkspace';
const bIsXcode = b.type === 'xcodeproj' || b.type === 'xcworkspace';
if (aIsXcode && !bIsXcode) return -1;
if (!aIsXcode && bIsXcode) return 1;
return a.name.localeCompare(b.name);
});
res.json({
path: targetPath,
parent: path.dirname(targetPath),
entries: result,
});
});
// --- List schemes for a project ---
app.get('/api/filesystem/schemes', requireLogin, async (req, res) => {
const projectPath = req.query.projectPath;
if (!projectPath) {
return res.status(400).json({ error: 'projectPath is required' });
}
if (!fs.existsSync(projectPath)) {
return res.status(404).json({ error: 'Project not found' });
}
if (!projectPath.endsWith('.xcodeproj') && !projectPath.endsWith('.xcworkspace')) {
return res.status(400).json({ error: 'Not an Xcode project or workspace' });
}
try {
const schemes = await buildWorker.listSchemesForPath(projectPath);
res.json({ schemes });
} catch (err) {
if (req.file && fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path);
res.status(500).json({ error: err.message });
}
});
// --- Clone a git repo ---
app.post('/api/build/git', requireLogin, async (req, res) => {
const { url, branch, scheme } = req.body || {};
if (!url) return res.status(400).json({ error: 'url is required' });
// --- Trigger a local build ---
app.post('/api/build/local', requireLogin, (req, res) => {
const { projectPath, scheme } = req.body || {};
if (!projectPath) {
return res.status(400).json({ error: 'projectPath is required' });
}
if (!fs.existsSync(projectPath)) {
return res.status(404).json({ error: 'Project not found' });
}
if (!projectPath.endsWith('.xcodeproj') && !projectPath.endsWith('.xcworkspace')) {
return res.status(400).json({ error: 'Not an Xcode project or workspace' });
}
const jobId = uuidv4();
const logPath = path.join(LOGS_DIR, `${jobId}.log`);
fs.writeFileSync(logPath, `Cloning ${url}${branch ? ` (branch ${branch})` : ''}\n`);
const projectName = path.basename(projectPath);
db.prepare(`
INSERT INTO build_jobs (id, source_kind, source_ref, scheme, status, log_path)
VALUES (?, 'git', ?, ?, 'pending', ?)
`).run(jobId, url, scheme || null, logPath);
INSERT INTO build_jobs (id, project_path, source_ref, scheme, status)
VALUES (?, ?, ?, ?, 'pending')
`).run(jobId, projectPath, projectName, scheme || null);
try {
const sourceDir = path.join(SOURCE_DIR, jobId);
await cloneGitRepo({ url, branch, destDir: sourceDir, logPath });
buildWorker.kick();
res.json({ success: true, job_id: jobId });
} catch (err) {
db.prepare("UPDATE build_jobs SET status = 'failed', error = ?, finished_at = datetime('now') WHERE id = ?")
.run(err.message, jobId);
res.status(500).json({ error: err.message });
}
buildWorker.kick();
res.json({ success: true, job_id: jobId });
});
// --- List jobs ---
app.get('/api/builds', requireLogin, (req, res) => {
const rows = db.prepare(`
SELECT id, bundle_id, source_kind, source_ref, scheme, status, started_at, finished_at, error, unraid_build_id, install_url
SELECT id, bundle_id, project_path, source_ref, scheme, status, started_at, finished_at, error, unraid_build_id, install_url
FROM build_jobs
ORDER BY COALESCE(started_at, created_at) DESC
LIMIT 100
@@ -167,7 +176,6 @@ function register(app, { requireLogin }) {
fs.readSync(fd, buf, 0, buf.length, position);
fs.closeSync(fd);
position = stat.size;
// SSE: prefix every line with "data: "
const lines = buf.toString('utf8').split('\n');
for (const line of lines) {
if (line.length) res.write(`data: ${line}\n\n`);
@@ -177,7 +185,6 @@ function register(app, { requireLogin }) {
sendNew();
const interval = setInterval(() => {
sendNew();
// Check if job finished — send one more time and close after a grace period.
const current = db.prepare('SELECT status FROM build_jobs WHERE id = ?').get(req.params.id);
if (current && (current.status === 'succeeded' || current.status === 'failed')) {
sendNew();
@@ -189,11 +196,6 @@ function register(app, { requireLogin }) {
req.on('close', () => clearInterval(interval));
});
// --- Rebuild a finished job (reuses the last known source if available) ---
app.post('/api/builds/:id/rebuild', requireLogin, (req, res) => {
res.status(501).json({ error: 'rebuild not implemented yet' });
});
}
module.exports = { register };

View File

@@ -1,4 +1,4 @@
// Build worker — consumes `build_jobs` rows, runs xcodebuild + fastlane + upload.
// Build worker — consumes `build_jobs` rows, runs xcodebuild + upload.
// Single in-process loop; SQLite is the queue.
const path = require('path');
@@ -11,11 +11,10 @@ const execFileAsync = promisify(execFile);
const { db, getSetting, DATA_DIR } = require('./db');
const profileManager = require('./profile-manager');
const SOURCE_DIR = path.join(DATA_DIR, 'source');
const BUILD_DIR = path.join(DATA_DIR, 'build');
const LOGS_DIR = path.join(DATA_DIR, 'builds');
[SOURCE_DIR, BUILD_DIR, LOGS_DIR].forEach((d) => fs.mkdirSync(d, { recursive: true }));
[BUILD_DIR, LOGS_DIR].forEach((d) => fs.mkdirSync(d, { recursive: true }));
const POLL_INTERVAL_MS = 2000;
let running = false;
@@ -62,28 +61,29 @@ function runCommand(cmd, args, { cwd, env, logPath }) {
});
}
// --- Project locator ---
// --- Scheme listing (standalone, no log file required) ---
function findProjectRoot(sourceDir) {
// Walk up to 3 levels looking for .xcodeproj/.xcworkspace.
const walk = (dir, depth) => {
if (depth > 3) return null;
const entries = fs.readdirSync(dir, { withFileTypes: true });
const workspace = entries.find((e) => e.isDirectory() && e.name.endsWith('.xcworkspace') && !e.name.endsWith('.xcodeproj/project.xcworkspace'));
if (workspace) return { dir, type: 'workspace', name: workspace.name };
const project = entries.find((e) => e.isDirectory() && e.name.endsWith('.xcodeproj'));
if (project) return { dir, type: 'project', name: project.name };
for (const e of entries) {
if (e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules') {
const found = walk(path.join(dir, e.name), depth + 1);
if (found) return found;
}
}
return null;
};
return walk(sourceDir, 0);
async function listSchemesForPath(projectPath) {
const projectName = path.basename(projectPath);
const projectDir = path.dirname(projectPath);
const type = projectPath.endsWith('.xcworkspace') ? 'workspace' : 'project';
const flag = type === 'workspace' ? '-workspace' : '-project';
const { stdout } = await execFileAsync('/usr/bin/xcodebuild', ['-list', '-json', flag, projectName], {
cwd: projectDir,
env: process.env,
timeout: 60000,
});
try {
const parsed = JSON.parse(stdout);
return parsed.workspace?.schemes || parsed.project?.schemes || [];
} catch {
return [];
}
}
// --- Build helpers ---
async function listSchemes({ projectRoot, logPath }) {
const args = projectRoot.type === 'workspace'
? ['-list', '-json', '-workspace', projectRoot.name]
@@ -98,7 +98,7 @@ async function listSchemes({ projectRoot, logPath }) {
}
async function getBuildSettings({ projectRoot, scheme, logPath }) {
const args = ['-showBuildSettings', '-json', '-scheme', scheme];
const args = ['-showBuildSettings', '-json', '-scheme', scheme, '-configuration', 'Release'];
if (projectRoot.type === 'workspace') args.unshift('-workspace', projectRoot.name);
else args.unshift('-project', projectRoot.name);
const { stdout } = await runCommand('/usr/bin/xcodebuild', args, { cwd: projectRoot.dir, env: process.env, logPath });
@@ -144,14 +144,14 @@ ${entries}
`;
}
// --- Upload to unraid ---
// --- Upload to storefront ---
async function uploadToUnraid({ ipaPath, notes, logPath }) {
const unraidUrl = getSetting('unraid_url');
const unraidToken = getSetting('unraid_token');
if (!unraidUrl || !unraidToken) throw new Error('unraid URL/token not configured');
async function uploadToStorefront({ ipaPath, notes, logPath }) {
const storefrontUrl = getSetting('unraid_url');
const storefrontToken = getSetting('unraid_token');
if (!storefrontUrl || !storefrontToken) throw new Error('Storefront URL/token not configured');
appendLog(logPath, `\nUploading IPA to ${unraidUrl}/api/upload`);
appendLog(logPath, `\nUploading IPA to ${storefrontUrl}/api/upload`);
const buf = fs.readFileSync(ipaPath);
const blob = new Blob([buf], { type: 'application/octet-stream' });
@@ -159,16 +159,16 @@ async function uploadToUnraid({ ipaPath, notes, logPath }) {
form.append('ipa', blob, path.basename(ipaPath));
if (notes) form.append('notes', notes);
const res = await fetch(`${unraidUrl}/api/upload`, {
const res = await fetch(`${storefrontUrl}/api/upload`, {
method: 'POST',
headers: { 'X-Api-Token': unraidToken },
headers: { 'X-Api-Token': storefrontToken },
body: form,
});
const body = await res.json().catch(() => ({}));
if (!res.ok || !body.success) {
throw new Error(`unraid upload failed (${res.status}): ${JSON.stringify(body)}`);
throw new Error(`Storefront upload failed (${res.status}): ${JSON.stringify(body)}`);
}
appendLog(logPath, `Uploaded: ${JSON.stringify(body)}`);
appendLog(logPath, `Uploaded: ${JSON.stringify(body)}`);
return body;
}
@@ -177,19 +177,25 @@ async function uploadToUnraid({ ipaPath, notes, logPath }) {
async function runBuild(job) {
const jobId = job.id;
const logPath = path.join(LOGS_DIR, `${jobId}.log`);
markStatus(jobId, 'preparing', { log_path: logPath, started_at: "datetime('now')" });
// Reset started_at properly (datetime() doesn't work via binding above).
markStatus(jobId, 'preparing', { log_path: logPath });
db.prepare("UPDATE build_jobs SET started_at = datetime('now') WHERE id = ?").run(jobId);
fs.writeFileSync(logPath, `Build ${jobId} started at ${new Date().toISOString()}\n`);
section(logPath, 'PREPARING');
const sourceDir = path.join(SOURCE_DIR, jobId);
if (!fs.existsSync(sourceDir)) throw new Error(`source dir missing: ${sourceDir}`);
// Resolve the local project path
const projectPath = job.project_path;
if (!projectPath || !fs.existsSync(projectPath)) {
throw new Error(`Project not found: ${projectPath}`);
}
const projectRoot = findProjectRoot(sourceDir);
if (!projectRoot) throw new Error('No .xcodeproj or .xcworkspace found in source');
appendLog(logPath, `Found ${projectRoot.type}: ${projectRoot.name} in ${projectRoot.dir}`);
const projectName = path.basename(projectPath);
const projectDir = path.dirname(projectPath);
const type = projectPath.endsWith('.xcworkspace') ? 'workspace' : 'project';
const projectRoot = { dir: projectDir, type, name: projectName };
appendLog(logPath, `Project: ${projectPath}`);
appendLog(logPath, `Type: ${type}, Name: ${projectName}`);
// Pick the scheme
const schemes = await listSchemes({ projectRoot, logPath });
@@ -207,7 +213,6 @@ async function runBuild(job) {
appendLog(logPath, `Team: ${teamId}`);
appendLog(logPath, `Bundle IDs: ${bundleIds.join(', ')}`);
// Persist the primary bundle id on the job row
db.prepare('UPDATE build_jobs SET bundle_id = ?, scheme = ? WHERE id = ?')
.run(bundleIds[0], scheme, jobId);
@@ -215,12 +220,17 @@ async function runBuild(job) {
section(logPath, 'SIGNING');
markStatus(jobId, 'signing');
if (!teamId) {
throw new Error('Could not determine DEVELOPMENT_TEAM from Xcode project');
}
const profilesByBundleId = {};
for (const bid of bundleIds) {
appendLog(logPath, `Ensuring profile for ${bid}`);
const info = await profileManager.getProfile(bid);
appendLog(logPath, `Ensuring profile for ${bid} (team ${teamId})...`);
const info = await profileManager.getProfile(bid, { teamId });
profilesByBundleId[bid] = info.profile_uuid;
appendLog(logPath, `${info.profile_uuid} (${info.fromCache ? 'cache' : 'fresh'}, ${info.device_count} devices, expires ${info.expires_at})`);
const source = info.fromCache ? 'cache' : info.fromDisk ? 'disk' : 'fastlane';
appendLog(logPath, ` -> ${info.profile_uuid} (${source}, ${info.device_count} devices, expires ${info.expires_at})`);
}
// --- Archiving phase ---
@@ -235,13 +245,9 @@ async function runBuild(job) {
'-destination', 'generic/platform=iOS',
'-archivePath', archivePath,
'-allowProvisioningUpdates',
'CODE_SIGN_STYLE=Manual',
`DEVELOPMENT_TEAM=${teamId}`,
'archive',
];
// We can't specify per-target PROVISIONING_PROFILE_SPECIFIER globally, so we rely on
// xcodebuild finding the installed profiles in ~/Library/MobileDevice/Provisioning Profiles/
// by matching bundle id + team id.
await runCommand('/usr/bin/xcodebuild', archiveArgs, { cwd: projectRoot.dir, env: process.env, logPath });
// --- Exporting phase ---
@@ -274,9 +280,9 @@ async function runBuild(job) {
section(logPath, 'UPLOADING');
markStatus(jobId, 'uploading', { ipa_path: ipaPath });
const uploadResult = await uploadToUnraid({
const uploadResult = await uploadToStorefront({
ipaPath,
notes: `Built by ${os.hostname()} job ${jobId}`,
notes: `Built from ${projectName} by ${os.hostname()} job ${jobId}`,
logPath,
});
@@ -285,9 +291,8 @@ async function runBuild(job) {
install_url: uploadResult.build?.install_url || null,
});
// --- Cleanup: keep log + IPA, remove source + archive ---
// Cleanup: remove archive only (keep IPA, never touch user's project)
try {
fs.rmSync(sourceDir, { recursive: true, force: true });
fs.rmSync(archivePath, { recursive: true, force: true });
} catch (e) {
appendLog(logPath, `Cleanup warning: ${e.message}`);
@@ -302,7 +307,7 @@ async function processJob(job) {
await runBuild(job);
} catch (err) {
console.error(`[build-worker] job ${job.id} failed:`, err);
try { appendLog(logPath, `\nFAILED: ${err.message}\n${err.stack || ''}`); } catch {}
try { appendLog(logPath, `\nFAILED: ${err.message}\n${err.stack || ''}`); } catch {}
markStatus(job.id, 'failed', { error: err.message });
}
}
@@ -322,7 +327,6 @@ async function loop() {
}
function kick() {
// Non-blocking: fire and forget.
loop().catch((err) => console.error('[build-worker] loop error:', err));
}
@@ -331,4 +335,4 @@ function start() {
kick();
}
module.exports = { start, kick, runBuild, processJob };
module.exports = { start, kick, listSchemesForPath };

View File

@@ -15,26 +15,6 @@ db.exec(`
value TEXT
);
CREATE TABLE IF NOT EXISTS devices (
udid TEXT PRIMARY KEY,
name TEXT,
model TEXT,
platform TEXT DEFAULT 'IOS',
apple_device_id TEXT,
synced_at TEXT,
added_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS apps (
id TEXT PRIMARY KEY,
bundle_id TEXT UNIQUE NOT NULL,
name TEXT,
scheme TEXT,
team_id TEXT,
last_built_at TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS profiles (
bundle_id TEXT PRIMARY KEY,
profile_uuid TEXT,
@@ -42,15 +22,15 @@ db.exec(`
team_id TEXT,
expires_at TEXT,
device_count INTEGER,
method TEXT DEFAULT 'ad-hoc',
path TEXT,
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS build_jobs (
id TEXT PRIMARY KEY,
app_id TEXT,
bundle_id TEXT,
source_kind TEXT,
project_path TEXT,
source_ref TEXT,
scheme TEXT,
status TEXT DEFAULT 'pending',
@@ -63,8 +43,38 @@ db.exec(`
error TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS asc_keys (
team_id TEXT PRIMARY KEY,
team_name TEXT,
key_id TEXT NOT NULL,
issuer_id TEXT NOT NULL,
p8_filename TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
`);
// Idempotent migrations for existing databases
try { db.exec("ALTER TABLE build_jobs ADD COLUMN project_path TEXT"); } catch {}
try { db.exec("ALTER TABLE profiles ADD COLUMN method TEXT DEFAULT 'ad-hoc'"); } catch {}
// Backfill asc_keys from legacy single-key settings (one-shot on first run after upgrade)
try {
const existing = db.prepare('SELECT COUNT(*) AS c FROM asc_keys').get();
if (existing.c === 0) {
const keyId = db.prepare("SELECT value FROM settings WHERE key = 'asc_key_id'").get()?.value;
const issuerId = db.prepare("SELECT value FROM settings WHERE key = 'asc_issuer_id'").get()?.value;
if (keyId && issuerId) {
const p8Path = path.join(DATA_DIR, 'asc', `${keyId}.p8`);
const p8Filename = fs.existsSync(p8Path) ? `${keyId}.p8` : null;
db.prepare(`
INSERT INTO asc_keys (team_id, team_name, key_id, issuer_id, p8_filename)
VALUES (?, ?, ?, ?, ?)
`).run('QND55P4443', 'Legacy', keyId, issuerId, p8Filename);
}
}
} catch {}
function getSetting(key) {
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
return row ? row.value : null;

View File

@@ -1,15 +1,15 @@
// Profile manager: wraps `fastlane sigh` to generate/cache ad-hoc provisioning profiles
// keyed by bundle identifier. Handles ASC key JSON materialization, profile parsing,
// cache invalidation, and installation into ~/Library/MobileDevice/Provisioning Profiles/.
// Profile manager: finds existing ad-hoc provisioning profiles on disk,
// or generates new ones via `fastlane sigh`. Caches results in SQLite.
const path = require('path');
const fs = require('fs');
const os = require('os');
const crypto = require('crypto');
const { execFile } = require('child_process');
const { promisify } = require('util');
const execFileAsync = promisify(execFile);
const { db, getSetting, DATA_DIR } = require('./db');
const { db, DATA_DIR } = require('./db');
const PROFILES_DIR = path.join(DATA_DIR, 'profiles');
const ASC_DIR = path.join(DATA_DIR, 'asc');
@@ -18,20 +18,31 @@ const INSTALLED_PROFILES_DIR = path.join(os.homedir(), 'Library/MobileDevice/Pro
fs.mkdirSync(PROFILES_DIR, { recursive: true });
// Minimum lifetime on a cached profile before we regenerate it proactively.
const MIN_LIFETIME_DAYS = 30;
function buildAscKeyJsonPath() {
const keyId = getSetting('asc_key_id');
const issuerId = getSetting('asc_issuer_id');
if (!keyId || !issuerId) throw new Error('ASC key id / issuer id not configured');
const p8Path = path.join(ASC_DIR, `${keyId}.p8`);
if (!fs.existsSync(p8Path)) throw new Error('.p8 file not uploaded');
const keyContent = fs.readFileSync(p8Path, 'utf8');
const jsonPath = path.join(ASC_DIR, `${keyId}.json`);
// --- ASC key resolution (per-team) ---
function listAscKeys() {
return db.prepare('SELECT * FROM asc_keys ORDER BY created_at').all();
}
function getAscKey(teamId) {
if (!teamId) throw new Error('teamId is required');
const row = db.prepare('SELECT * FROM asc_keys WHERE team_id = ?').get(teamId);
if (!row) throw new Error(`No ASC key configured for team ${teamId}. Add it on the Settings page.`);
if (!row.p8_filename) throw new Error(`ASC key for team ${teamId} exists but .p8 has not been uploaded.`);
const p8Path = path.join(ASC_DIR, row.p8_filename);
if (!fs.existsSync(p8Path)) throw new Error(`.p8 file missing on disk for team ${teamId}: ${row.p8_filename}`);
return { ...row, p8Path };
}
function buildAscKeyJsonPath(teamId) {
const key = getAscKey(teamId);
const keyContent = fs.readFileSync(key.p8Path, 'utf8');
const jsonPath = path.join(ASC_DIR, `${key.key_id}.json`);
const json = {
key_id: keyId,
issuer_id: issuerId,
key_id: key.key_id,
issuer_id: key.issuer_id,
key: keyContent,
duration: 1200,
in_house: false,
@@ -40,9 +51,9 @@ function buildAscKeyJsonPath() {
return jsonPath;
}
// --- Profile parser ---
function parseMobileprovision(filePath) {
// Extract the plist contents from the CMS-wrapped .mobileprovision via `security cms -D`.
// Falls back to a regex scan if `security` isn't available.
const { execFileSync } = require('child_process');
let xml;
try {
@@ -74,11 +85,30 @@ function parseMobileprovision(filePath) {
|| (xml.match(/<key>TeamIdentifier<\/key>\s*<array>\s*<string>([^<]+)<\/string>/)?.[1] ?? null);
const expiresAt = pickDate('ExpirationDate');
// Devices count from the ProvisionedDevices array.
// Device count from the ProvisionedDevices array
const devicesMatch = xml.match(/<key>ProvisionedDevices<\/key>\s*<array>([\s\S]*?)<\/array>/);
const deviceCount = devicesMatch ? (devicesMatch[1].match(/<string>/g) || []).length : 0;
return { uuid, name, teamId, expiresAt, deviceCount };
// Distribution method detection
const hasDevices = xml.includes('<key>ProvisionedDevices</key>');
const provisionsAll = xml.includes('<key>ProvisionsAllDevices</key>');
const getTaskAllow = xml.includes('<key>get-task-allow</key>')
&& /<key>get-task-allow<\/key>\s*<true\s*\/>/.test(xml);
let method;
if (provisionsAll) method = 'enterprise';
else if (hasDevices && !getTaskAllow) method = 'ad-hoc';
else if (hasDevices && getTaskAllow) method = 'development';
else method = 'app-store';
// Bundle ID from application-identifier entitlement
const appIdMatch = xml.match(/<key>application-identifier<\/key>\s*<string>([^<]+)<\/string>/);
const applicationIdentifier = appIdMatch ? appIdMatch[1] : null;
const bundleIdentifier = applicationIdentifier
? applicationIdentifier.replace(/^[A-Z0-9]+\./, '')
: null;
return { uuid, name, teamId, expiresAt, deviceCount, method, bundleIdentifier, filePath };
}
function installProfile(srcPath, uuid) {
@@ -88,6 +118,68 @@ function installProfile(srcPath, uuid) {
return dest;
}
// --- Scan installed profiles ---
function scanInstalledProfiles(bundleId) {
if (!fs.existsSync(INSTALLED_PROFILES_DIR)) return [];
const files = fs.readdirSync(INSTALLED_PROFILES_DIR)
.filter((f) => f.endsWith('.mobileprovision'));
const matches = [];
for (const file of files) {
try {
const filePath = path.join(INSTALLED_PROFILES_DIR, file);
const info = parseMobileprovision(filePath);
if (info.method !== 'ad-hoc') continue;
// Match bundle ID: exact match or wildcard (e.g., "TEAM.*" matches anything)
if (info.bundleIdentifier === bundleId || info.bundleIdentifier === '*') {
// Check not expired
if (info.expiresAt) {
const expiresMs = Date.parse(info.expiresAt);
if (!Number.isNaN(expiresMs) && expiresMs > Date.now()) {
matches.push(info);
}
}
}
} catch {
// Skip unparseable profiles
}
}
// Sort by expiry date descending (longest remaining validity first)
matches.sort((a, b) => {
const aExp = Date.parse(a.expiresAt) || 0;
const bExp = Date.parse(b.expiresAt) || 0;
return bExp - aExp;
});
return matches;
}
function scanAllInstalledProfiles() {
if (!fs.existsSync(INSTALLED_PROFILES_DIR)) return [];
const files = fs.readdirSync(INSTALLED_PROFILES_DIR)
.filter((f) => f.endsWith('.mobileprovision'));
const profiles = [];
for (const file of files) {
try {
const filePath = path.join(INSTALLED_PROFILES_DIR, file);
const info = parseMobileprovision(filePath);
profiles.push(info);
} catch {
// Skip unparseable
}
}
return profiles;
}
// --- DB cache helpers ---
function cachedRow(bundleId) {
return db.prepare('SELECT * FROM profiles WHERE bundle_id = ?').get(bundleId);
}
@@ -101,13 +193,41 @@ function isCacheFresh(row) {
return daysLeft >= MIN_LIFETIME_DAYS;
}
async function runFastlaneSigh({ bundleId, outputPath, apiKeyJson, logStream }) {
function upsertProfileCache(bundleId, info) {
db.prepare(`
INSERT INTO profiles (bundle_id, profile_uuid, name, team_id, expires_at, device_count, method, path, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(bundle_id) DO UPDATE SET
profile_uuid = excluded.profile_uuid,
name = excluded.name,
team_id = excluded.team_id,
expires_at = excluded.expires_at,
device_count = excluded.device_count,
method = excluded.method,
path = excluded.path,
updated_at = excluded.updated_at
`).run(
bundleId,
info.uuid,
info.name,
info.teamId,
info.expiresAt,
info.deviceCount,
info.method || 'ad-hoc',
info.filePath,
);
}
// --- Fastlane sigh ---
async function runFastlaneSigh({ bundleId, teamId, outputPath, apiKeyJson, logStream }) {
const args = [
'run',
'sigh',
`adhoc:true`,
`force:true`,
`app_identifier:${bundleId}`,
`team_id:${teamId}`,
`api_key_path:${apiKeyJson}`,
`output_path:${outputPath}`,
`skip_install:true`,
@@ -137,23 +257,52 @@ async function runFastlaneSigh({ bundleId, outputPath, apiKeyJson, logStream })
});
}
async function getProfile(bundleId, { force = false, logStream = null } = {}) {
if (!bundleId) throw new Error('bundleId is required');
// --- Main profile resolution ---
const existing = cachedRow(bundleId);
if (!force && isCacheFresh(existing)) {
// Make sure it's installed locally so xcodebuild can find it.
try { installProfile(existing.path, existing.profile_uuid); } catch {}
return { ...existing, fromCache: true };
async function getProfile(bundleId, { teamId, force = false, logStream = null } = {}) {
if (!bundleId) throw new Error('bundleId is required');
if (!teamId) throw new Error('teamId is required');
// 1. Check DB cache
if (!force) {
const existing = cachedRow(bundleId);
if (isCacheFresh(existing)) {
try { installProfile(existing.path, existing.profile_uuid); } catch {}
return { ...existing, fromCache: true };
}
}
const apiKeyJson = buildAscKeyJsonPath();
// 2. Scan installed profiles on disk
if (!force) {
const installed = scanInstalledProfiles(bundleId);
if (installed.length > 0) {
const best = installed[0];
const daysLeft = (Date.parse(best.expiresAt) - Date.now()) / (1000 * 60 * 60 * 24);
if (daysLeft >= MIN_LIFETIME_DAYS) {
upsertProfileCache(bundleId, best);
return {
bundle_id: bundleId,
profile_uuid: best.uuid,
name: best.name,
team_id: best.teamId,
expires_at: best.expiresAt,
device_count: best.deviceCount,
method: best.method,
path: best.filePath,
fromCache: false,
fromDisk: true,
};
}
}
}
// 3. Generate via fastlane sigh
const apiKeyJson = buildAscKeyJsonPath(teamId);
const outputPath = path.join(PROFILES_DIR, bundleId);
fs.mkdirSync(outputPath, { recursive: true });
await runFastlaneSigh({ bundleId, outputPath, apiKeyJson, logStream });
await runFastlaneSigh({ bundleId, teamId, outputPath, apiKeyJson, logStream });
// Find the .mobileprovision fastlane produced.
const candidates = fs.readdirSync(outputPath)
.filter((f) => f.endsWith('.mobileprovision'))
.map((f) => ({
@@ -171,33 +320,13 @@ async function getProfile(bundleId, { force = false, logStream = null } = {}) {
const parsed = parseMobileprovision(produced.path);
if (!parsed.uuid) throw new Error('Could not parse UUID from produced profile');
// Normalize storage: rename to <uuid>.mobileprovision inside the per-bundle dir.
const finalPath = path.join(outputPath, `${parsed.uuid}.mobileprovision`);
if (produced.path !== finalPath) {
fs.renameSync(produced.path, finalPath);
}
parsed.filePath = finalPath;
db.prepare(`
INSERT INTO profiles (bundle_id, profile_uuid, name, team_id, expires_at, device_count, path, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(bundle_id) DO UPDATE SET
profile_uuid = excluded.profile_uuid,
name = excluded.name,
team_id = excluded.team_id,
expires_at = excluded.expires_at,
device_count = excluded.device_count,
path = excluded.path,
updated_at = excluded.updated_at
`).run(
bundleId,
parsed.uuid,
parsed.name,
parsed.teamId,
parsed.expiresAt,
parsed.deviceCount,
finalPath,
);
upsertProfileCache(bundleId, parsed);
installProfile(finalPath, parsed.uuid);
return {
@@ -207,9 +336,132 @@ async function getProfile(bundleId, { force = false, logStream = null } = {}) {
team_id: parsed.teamId,
expires_at: parsed.expiresAt,
device_count: parsed.deviceCount,
method: parsed.method,
path: finalPath,
fromCache: false,
};
}
module.exports = { getProfile, parseMobileprovision };
// --- ASC API: fetch registered bundle IDs ---
function b64url(buf) {
return Buffer.from(buf).toString('base64url');
}
const jwtCache = new Map(); // teamId -> { jwt, exp }
function signAscJwt(teamId) {
const cached = jwtCache.get(teamId);
if (cached && Date.now() / 1000 < cached.exp - 60) return cached.jwt;
const key = getAscKey(teamId);
const privateKey = fs.readFileSync(key.p8Path, 'utf8');
const now = Math.floor(Date.now() / 1000);
const exp = now + 1200;
const header = b64url(JSON.stringify({ alg: 'ES256', kid: key.key_id, typ: 'JWT' }));
const payload = b64url(JSON.stringify({ iss: key.issuer_id, iat: now, exp, aud: 'appstoreconnect-v1' }));
const sig = crypto.sign('sha256', Buffer.from(`${header}.${payload}`), { key: privateKey, dsaEncoding: 'ieee-p1363' });
const jwt = `${header}.${payload}.${b64url(sig)}`;
jwtCache.set(teamId, { jwt, exp });
return jwt;
}
function invalidateJwtCache(teamId) {
if (teamId) jwtCache.delete(teamId);
else jwtCache.clear();
}
async function fetchBundleIdsForTeam(teamId) {
const jwt = signAscJwt(teamId);
const results = [];
let url = 'https://api.appstoreconnect.apple.com/v1/bundleIds?limit=200&sort=identifier';
while (url) {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${jwt}` },
});
if (!res.ok) {
const body = await res.text();
throw new Error(`ASC API error (${res.status}): ${body.slice(0, 500)}`);
}
const json = await res.json();
for (const item of json.data || []) {
results.push({
id: item.id,
identifier: item.attributes.identifier,
name: item.attributes.name,
platform: item.attributes.platform,
});
}
url = json.links?.next || null;
}
return results;
}
async function fetchAllBundleIds() {
const keys = listAscKeys();
const groups = [];
for (const key of keys) {
try {
const bundleIds = await fetchBundleIdsForTeam(key.team_id);
groups.push({
teamId: key.team_id,
teamName: key.team_name || key.team_id,
bundleIds,
error: null,
});
} catch (err) {
groups.push({
teamId: key.team_id,
teamName: key.team_name || key.team_id,
bundleIds: [],
error: err.message,
});
}
}
return groups;
}
async function testAscKey(teamId) {
const jwt = signAscJwt(teamId);
const res = await fetch('https://api.appstoreconnect.apple.com/v1/apps?limit=1', {
headers: { Authorization: `Bearer ${jwt}` },
});
if (!res.ok) {
const body = await res.text();
throw new Error(`ASC API (${res.status}): ${body.slice(0, 300)}`);
}
const json = await res.json();
return { ok: true, app_count_sample: (json.data || []).length };
}
// --- Delete a profile ---
function deleteProfile(uuid) {
// Remove from installed profiles dir
const installedPath = path.join(INSTALLED_PROFILES_DIR, `${uuid}.mobileprovision`);
if (fs.existsSync(installedPath)) fs.unlinkSync(installedPath);
// Remove from DB cache
db.prepare('DELETE FROM profiles WHERE profile_uuid = ?').run(uuid);
}
module.exports = {
getProfile,
parseMobileprovision,
scanInstalledProfiles,
scanAllInstalledProfiles,
deleteProfile,
installProfile,
listAscKeys,
getAscKey,
fetchBundleIdsForTeam,
fetchAllBundleIds,
testAscKey,
invalidateJwtCache,
INSTALLED_PROFILES_DIR,
ASC_DIR,
};

View File

@@ -21,7 +21,8 @@ const path = require('path');
const fs = require('fs');
const { db, getSetting, setSetting, DATA_DIR } = require('./db');
const { requireLogin, requireBuilderSecret, validatePassword } = require('./auth');
const { requireLogin, validatePassword } = require('./auth');
const profileManager = require('./profile-manager');
const app = express();
const PORT = process.env.PORT || 3090;
@@ -78,173 +79,123 @@ app.get('/settings', requireLogin, (req, res) => {
res.sendFile(path.join(__dirname, '..', 'views', 'settings.html'));
});
app.get('/devices', requireLogin, (req, res) => {
res.sendFile(path.join(__dirname, '..', 'views', 'devices.html'));
app.get('/profiles', requireLogin, (req, res) => {
res.sendFile(path.join(__dirname, '..', 'views', 'profiles.html'));
});
// --- Device API ---
// --- Profile management API ---
function invalidateProfilesForDeviceChange() {
db.prepare('UPDATE profiles SET updated_at = NULL').run();
}
app.get('/api/devices', requireLogin, (req, res) => {
const rows = db.prepare('SELECT * FROM devices ORDER BY added_at DESC').all();
res.json(rows);
});
app.post('/api/devices', requireLogin, async (req, res) => {
const { udid, name, model, platform = 'IOS' } = req.body || {};
if (!udid || typeof udid !== 'string') {
return res.status(400).json({ error: 'UDID is required' });
}
// Upsert locally first so we always have a record even if Apple call fails.
db.prepare(`
INSERT INTO devices (udid, name, model, platform)
VALUES (?, ?, ?, ?)
ON CONFLICT(udid) DO UPDATE SET
name = COALESCE(NULLIF(excluded.name, ''), devices.name),
model = COALESCE(NULLIF(excluded.model, ''), devices.model),
platform = excluded.platform
`).run(udid, name || null, model || null, platform);
// Try to register with Apple.
let synced = false;
app.get('/api/profiles', requireLogin, (req, res) => {
try {
const asc = require('./asc-api');
const appleDevice = await asc.registerDevice({ udid, name, platform });
const appleDeviceId = appleDevice?.id || null;
db.prepare(`
UPDATE devices
SET apple_device_id = ?, synced_at = datetime('now')
WHERE udid = ?
`).run(appleDeviceId, udid);
synced = true;
invalidateProfilesForDeviceChange();
const profiles = profileManager.scanAllInstalledProfiles();
const now = Date.now();
const result = profiles.map((p) => {
const expiresMs = p.expiresAt ? Date.parse(p.expiresAt) : null;
let status = 'unknown';
if (expiresMs) {
const daysLeft = (expiresMs - now) / (1000 * 60 * 60 * 24);
if (daysLeft <= 0) status = 'expired';
else if (daysLeft < 30) status = 'expiring';
else status = 'valid';
}
return {
uuid: p.uuid,
name: p.name,
bundleIdentifier: p.bundleIdentifier,
teamId: p.teamId,
method: p.method,
expiresAt: p.expiresAt,
deviceCount: p.deviceCount,
status,
filePath: p.filePath,
};
});
// Sort: ad-hoc first, then by bundle ID
result.sort((a, b) => {
if (a.method === 'ad-hoc' && b.method !== 'ad-hoc') return -1;
if (a.method !== 'ad-hoc' && b.method === 'ad-hoc') return 1;
return (a.bundleIdentifier || '').localeCompare(b.bundleIdentifier || '');
});
res.json(result);
} catch (err) {
// Don't fail the request; the device is saved locally.
console.warn('[devices] ASC sync failed:', err.message);
return res.json({ success: true, synced: false, warning: err.message });
res.status(500).json({ error: err.message });
}
res.json({ success: true, synced });
});
app.delete('/api/devices/:udid', requireLogin, (req, res) => {
db.prepare('DELETE FROM devices WHERE udid = ?').run(req.params.udid);
invalidateProfilesForDeviceChange();
res.json({ success: true });
});
// --- Enrollment bridge (called by unraid's /enroll/callback over the LAN) ---
app.post('/api/devices/from-enrollment', requireBuilderSecret, async (req, res) => {
const { udid, name, model, platform = 'IOS' } = req.body || {};
if (!udid || typeof udid !== 'string') {
return res.status(400).json({ error: 'UDID is required' });
}
db.prepare(`
INSERT INTO devices (udid, name, model, platform)
VALUES (?, ?, ?, ?)
ON CONFLICT(udid) DO UPDATE SET
name = COALESCE(NULLIF(excluded.name, ''), devices.name),
model = COALESCE(NULLIF(excluded.model, ''), devices.model),
platform = excluded.platform
`).run(udid, name || null, model || null, platform);
let synced = false;
app.get('/api/bundle-ids', requireLogin, async (req, res) => {
try {
const asc = require('./asc-api');
const appleDevice = await asc.registerDevice({ udid, name, platform });
const appleDeviceId = appleDevice?.id || null;
db.prepare(`
UPDATE devices
SET apple_device_id = ?, synced_at = datetime('now')
WHERE udid = ?
`).run(appleDeviceId, udid);
synced = true;
invalidateProfilesForDeviceChange();
const groups = await profileManager.fetchAllBundleIds();
res.json(groups);
} catch (err) {
console.warn('[enrollment] ASC sync failed:', err.message);
return res.json({ success: true, synced: false, warning: err.message });
res.status(500).json({ error: err.message });
}
res.json({ success: true, synced });
});
// --- Settings API ---
app.delete('/api/profiles/:uuid', requireLogin, (req, res) => {
try {
profileManager.deleteProfile(req.params.uuid);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
const SETTINGS_KEYS = [
'asc_key_id',
'asc_issuer_id',
'unraid_url',
'unraid_token',
];
app.post('/api/profiles/regenerate', requireLogin, async (req, res) => {
const { bundleId, teamId } = req.body || {};
if (!bundleId) return res.status(400).json({ error: 'bundleId is required' });
if (!teamId) return res.status(400).json({ error: 'teamId is required' });
try {
const info = await profileManager.getProfile(bundleId, { teamId, force: true });
res.json({ success: true, profile: info });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.post('/api/profiles/generate', requireLogin, async (req, res) => {
const { bundleId, teamId } = req.body || {};
if (!bundleId) return res.status(400).json({ error: 'bundleId is required' });
if (!teamId) return res.status(400).json({ error: 'teamId is required' });
try {
const info = await profileManager.getProfile(bundleId, { teamId, force: true });
res.json({ success: true, profile: info });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// --- Settings API (storefront only now; ASC keys have their own endpoints) ---
app.get('/api/settings', requireLogin, (req, res) => {
const out = {};
for (const k of SETTINGS_KEYS) {
out[k] = getSetting(k) || '';
}
// Never expose the raw token; just indicate whether it's set
out.unraid_token = out.unraid_token ? '••••••••' : '';
// Has the p8 been uploaded?
const keyId = out.asc_key_id;
out.asc_key_uploaded = keyId
? fs.existsSync(path.join(DATA_DIR, 'asc', `${keyId}.p8`))
: false;
const out = {
unraid_url: getSetting('unraid_url') || '',
unraid_token: getSetting('unraid_token') ? '••••••••' : '',
};
res.json(out);
});
app.post('/api/settings', requireLogin, (req, res) => {
const { asc_key_id, asc_issuer_id, unraid_url, unraid_token } = req.body;
if (asc_key_id !== undefined) setSetting('asc_key_id', asc_key_id || '');
if (asc_issuer_id !== undefined) setSetting('asc_issuer_id', asc_issuer_id || '');
const { unraid_url, unraid_token } = req.body;
if (unraid_url !== undefined) setSetting('unraid_url', unraid_url || '');
// Only update the token if a real value was provided (not the placeholder)
if (unraid_token && unraid_token !== '••••••••') {
setSetting('unraid_token', unraid_token);
}
res.json({ success: true });
});
app.post('/api/settings/p8', requireLogin, p8Upload.single('p8'), (req, res) => {
try {
if (!req.file) return res.status(400).json({ error: 'No file' });
const keyId = getSetting('asc_key_id');
if (!keyId) {
fs.unlinkSync(req.file.path);
return res.status(400).json({ error: 'Save Key ID before uploading .p8' });
}
const dest = path.join(ASC_DIR, `${keyId}.p8`);
fs.renameSync(req.file.path, dest);
fs.chmodSync(dest, 0o600);
res.json({ success: true });
} catch (err) {
if (req.file && fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path);
res.status(500).json({ error: err.message });
}
});
app.post('/api/settings/test-asc', requireLogin, async (req, res) => {
try {
const asc = require('./asc-api');
const devices = await asc.listDevices();
res.json({ success: true, device_count: devices.length });
} catch (err) {
res.status(400).json({ error: err.message });
}
});
app.post('/api/settings/test-unraid', requireLogin, async (req, res) => {
app.post('/api/settings/test-storefront', requireLogin, async (req, res) => {
try {
const url = getSetting('unraid_url');
const token = getSetting('unraid_token');
if (!url || !token) return res.status(400).json({ error: 'Set URL and token first' });
const r = await fetch(`${url}/api/apps`, { headers: { 'X-Api-Token': token } });
if (!r.ok) return res.status(400).json({ error: `unraid returned ${r.status}` });
if (!r.ok) return res.status(400).json({ error: `Storefront returned ${r.status}` });
const apps = await r.json();
res.json({ success: true, app_count: apps.length });
} catch (err) {
@@ -252,35 +203,106 @@ app.post('/api/settings/test-unraid', requireLogin, async (req, res) => {
}
});
// --- ASC keys API (per-team developer accounts) ---
app.get('/api/asc-keys', requireLogin, (req, res) => {
try {
const keys = profileManager.listAscKeys();
const out = keys.map((k) => ({
team_id: k.team_id,
team_name: k.team_name,
key_id: k.key_id,
issuer_id: k.issuer_id,
p8_uploaded: !!k.p8_filename && fs.existsSync(path.join(profileManager.ASC_DIR, k.p8_filename)),
created_at: k.created_at,
}));
res.json(out);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.post('/api/asc-keys', requireLogin, (req, res) => {
const { team_id, team_name, key_id, issuer_id } = req.body || {};
if (!team_id || !key_id || !issuer_id) {
return res.status(400).json({ error: 'team_id, key_id, and issuer_id are required' });
}
try {
db.prepare(`
INSERT INTO asc_keys (team_id, team_name, key_id, issuer_id)
VALUES (?, ?, ?, ?)
ON CONFLICT(team_id) DO UPDATE SET
team_name = excluded.team_name,
key_id = excluded.key_id,
issuer_id = excluded.issuer_id
`).run(team_id, team_name || team_id, key_id, issuer_id);
profileManager.invalidateJwtCache(team_id);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.post('/api/asc-keys/:team_id/p8', requireLogin, p8Upload.single('p8'), (req, res) => {
try {
if (!req.file) return res.status(400).json({ error: 'No file' });
const row = db.prepare('SELECT * FROM asc_keys WHERE team_id = ?').get(req.params.team_id);
if (!row) {
fs.unlinkSync(req.file.path);
return res.status(404).json({ error: 'Team not found — save key details first' });
}
const filename = `${row.key_id}.p8`;
const dest = path.join(profileManager.ASC_DIR, filename);
fs.renameSync(req.file.path, dest);
fs.chmodSync(dest, 0o600);
db.prepare('UPDATE asc_keys SET p8_filename = ? WHERE team_id = ?').run(filename, row.team_id);
profileManager.invalidateJwtCache(row.team_id);
res.json({ success: true });
} catch (err) {
if (req.file && fs.existsSync(req.file.path)) {
try { fs.unlinkSync(req.file.path); } catch {}
}
res.status(500).json({ error: err.message });
}
});
app.delete('/api/asc-keys/:team_id', requireLogin, (req, res) => {
try {
const row = db.prepare('SELECT * FROM asc_keys WHERE team_id = ?').get(req.params.team_id);
if (!row) return res.status(404).json({ error: 'Team not found' });
if (row.p8_filename) {
const p8Path = path.join(profileManager.ASC_DIR, row.p8_filename);
if (fs.existsSync(p8Path)) fs.unlinkSync(p8Path);
const jsonPath = path.join(profileManager.ASC_DIR, `${row.key_id}.json`);
if (fs.existsSync(jsonPath)) fs.unlinkSync(jsonPath);
}
db.prepare('DELETE FROM asc_keys WHERE team_id = ?').run(row.team_id);
profileManager.invalidateJwtCache(row.team_id);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.post('/api/asc-keys/:team_id/test', requireLogin, async (req, res) => {
try {
const result = await profileManager.testAscKey(req.params.team_id);
res.json({ success: true, ...result });
} catch (err) {
res.status(400).json({ error: err.message });
}
});
// --- Build pipeline ---
require('./build-routes').register(app, { requireLogin });
// --- Profile API ---
app.get('/api/profile/:bundleId', requireLogin, async (req, res) => {
try {
const profileManager = require('./profile-manager');
const force = req.query.force === '1';
const info = await profileManager.getProfile(req.params.bundleId, { force });
if (req.query.download === '1') {
res.set('Content-Type', 'application/x-apple-aspen-config');
res.set('Content-Disposition', `attachment; filename="${info.profile_uuid}.mobileprovision"`);
return res.sendFile(info.path);
}
res.json({ success: true, profile: info });
} catch (err) {
console.error('[profile]', err);
res.status(500).json({ error: err.message });
}
});
// --- Health ---
app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
version: '1.0.0',
version: '2.0.0',
service: 'ios-appstore-builder',
host: require('os').hostname(),
});
@@ -289,7 +311,6 @@ app.get('/api/health', (req, res) => {
app.listen(PORT, '0.0.0.0', () => {
console.log(`iOS App Store Builder running on port ${PORT}`);
console.log(`Data dir: ${DATA_DIR}`);
// Start the build worker loop.
require('./build-worker').start();
console.log('Build worker started');
});

View File

@@ -1,9 +1,10 @@
<!-- Include pattern note: this file is not served directly; each page hand-inlines the nav for simplicity. -->
<header>
<div class="header-left"><h1>🔨 Builder</h1></div>
<div class="header-left"><h1>Builder</h1></div>
<nav>
<a href="/">Builds</a>
<a href="/devices">Devices</a>
<a href="/build">New Build</a>
<a href="/profiles">Profiles</a>
<a href="/settings">Settings</a>
<a href="/logout" class="logout">Logout</a>
</nav>

View File

@@ -8,11 +8,11 @@
</head>
<body>
<header>
<div class="header-left"><h1>🔨 Builder</h1></div>
<div class="header-left"><h1>Builder</h1></div>
<nav>
<a href="/">Builds</a>
<a href="/build" class="active">New Build</a>
<a href="/devices">Devices</a>
<a href="/profiles">Profiles</a>
<a href="/settings">Settings</a>
<a href="/logout" class="logout">Logout</a>
</nav>
@@ -22,40 +22,26 @@
<h1 class="page-title">New Build</h1>
<div class="section">
<h2>From source archive</h2>
<h2>Select Xcode Project</h2>
<div class="card">
<form id="upload-form" enctype="multipart/form-data">
<label>Archive (.zip or .tar.gz)</label>
<input type="file" name="source" id="source-input" accept=".zip,.tar.gz,.tgz" required>
<label>Scheme (optional)</label>
<input type="text" name="scheme" placeholder="leave blank to use the first scheme">
<div class="btn-row">
<button type="submit">Queue Build</button>
</div>
</form>
<div id="path-bar" class="path-bar"></div>
<div id="file-list" class="file-list">
<p style="color:var(--text-muted)">Loading...</p>
</div>
</div>
</div>
<div class="section">
<h2>From git URL</h2>
<div id="project-config" class="section" style="display:none">
<h2>Build Configuration</h2>
<div class="card">
<form id="git-form">
<label>Repository URL</label>
<input type="text" name="url" placeholder="git@gitea.treytartt.com:user/repo.git or https://…">
<div class="field-group">
<div>
<label>Branch (optional)</label>
<input type="text" name="branch" placeholder="main">
</div>
<div>
<label>Scheme (optional)</label>
<input type="text" name="scheme" placeholder="first scheme">
</div>
</div>
<div class="btn-row">
<button type="submit">Queue Build</button>
</div>
</form>
<p id="selected-project" class="mono" style="font-size:13px;color:var(--text-muted);margin-bottom:12px"></p>
<label>Scheme</label>
<select id="scheme-select" disabled>
<option>Select a project first</option>
</select>
<div class="btn-row">
<button id="build-btn" type="button" disabled>Build</button>
</div>
</div>
</div>

View File

@@ -8,11 +8,11 @@
</head>
<body>
<header>
<div class="header-left"><h1>🔨 Builder</h1></div>
<div class="header-left"><h1>Builder</h1></div>
<nav>
<a href="/" class="active">Builds</a>
<a href="/build">New Build</a>
<a href="/devices">Devices</a>
<a href="/profiles">Profiles</a>
<a href="/settings">Settings</a>
<a href="/logout" class="logout">Logout</a>
</nav>

View File

@@ -1,56 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Devices - Builder</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<header>
<div class="header-left"><h1>🔨 Builder</h1></div>
<nav>
<a href="/">Builds</a>
<a href="/devices" class="active">Devices</a>
<a href="/settings">Settings</a>
<a href="/logout" class="logout">Logout</a>
</nav>
</header>
<main>
<h1 class="page-title">Devices</h1>
<div class="section">
<h2>Register a device</h2>
<div class="card">
<form id="add-form">
<div class="field-group">
<div>
<label>UDID</label>
<input type="text" name="udid" placeholder="40-char hex or 25-char UUID format" required autocomplete="off">
</div>
<div>
<label>Name</label>
<input type="text" name="name" placeholder="Trey's iPhone" autocomplete="off">
</div>
</div>
<div class="btn-row">
<button type="submit">Add Device</button>
</div>
</form>
</div>
</div>
<div class="section">
<h2>Registered devices</h2>
<div id="devices-container">
<div class="card"><p style="color:var(--text-muted)">Loading…</p></div>
</div>
</div>
<div id="toast" class="toast"></div>
</main>
<script src="/js/devices.js"></script>
</body>
</html>

View File

@@ -8,10 +8,11 @@
</head>
<body>
<header>
<div class="header-left"><h1>🔨 Builder</h1></div>
<div class="header-left"><h1>Builder</h1></div>
<nav>
<a href="/" class="active">Builds</a>
<a href="/devices">Devices</a>
<a href="/build">New Build</a>
<a href="/profiles">Profiles</a>
<a href="/settings">Settings</a>
<a href="/logout" class="logout">Logout</a>
</nav>

View File

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Profiles - Builder</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<header>
<div class="header-left"><h1>Builder</h1></div>
<nav>
<a href="/">Builds</a>
<a href="/build">New Build</a>
<a href="/profiles" class="active">Profiles</a>
<a href="/settings">Settings</a>
<a href="/logout" class="logout">Logout</a>
</nav>
</header>
<main>
<h1 class="page-title">Provisioning Profiles</h1>
<div class="section">
<h2>App Store Connect Bundle IDs</h2>
<div id="bundle-ids-container">
<div class="card"><p style="color:var(--text-muted)">Loading bundle IDs from Apple...</p></div>
</div>
</div>
<div class="section">
<h2>Installed Ad-Hoc Profiles</h2>
<div id="profiles-container">
<div class="card"><p style="color:var(--text-muted)">Loading...</p></div>
</div>
</div>
<div id="toast" class="toast"></div>
</main>
<script src="/js/profiles.js"></script>
</body>
</html>

View File

@@ -8,10 +8,11 @@
</head>
<body>
<header>
<div class="header-left"><h1>🔨 Builder</h1></div>
<div class="header-left"><h1>Builder</h1></div>
<nav>
<a href="/">Builds</a>
<a href="/devices">Devices</a>
<a href="/build">New Build</a>
<a href="/profiles">Profiles</a>
<a href="/settings" class="active">Settings</a>
<a href="/logout" class="logout">Logout</a>
</nav>
@@ -21,41 +22,58 @@
<h1 class="page-title">Settings</h1>
<div class="section">
<h2>App Store Connect API</h2>
<h2>Developer Accounts</h2>
<p style="font-size:13px;color:var(--text-muted);margin-bottom:12px">
One App Store Connect API key per Apple Developer team. Used by fastlane to generate ad-hoc provisioning profiles.
The build worker auto-picks the key whose <code>team_id</code> matches the Xcode project's <code>DEVELOPMENT_TEAM</code>.
</p>
<div class="card">
<form id="asc-form">
<div id="asc-keys-table"></div>
</div>
<h3 style="margin-top:20px">Add Developer Account</h3>
<div class="card">
<form id="add-key-form">
<div class="field-group">
<div>
<label>Team Name (label)</label>
<input type="text" name="team_name" placeholder="88Oak Apps" autocomplete="off" required>
</div>
<div>
<label>Team ID</label>
<input type="text" name="team_id" placeholder="ABCDE12345" autocomplete="off" required>
</div>
</div>
<div class="field-group">
<div>
<label>Key ID</label>
<input type="text" name="asc_key_id" placeholder="ABC123DEF4" autocomplete="off">
<input type="text" name="key_id" placeholder="ABC123DEF4" autocomplete="off" required>
</div>
<div>
<label>Issuer ID</label>
<input type="text" name="asc_issuer_id" placeholder="00000000-0000-0000-0000-000000000000" autocomplete="off">
<input type="text" name="issuer_id" placeholder="00000000-0000-0000-0000-000000000000" autocomplete="off" required>
</div>
</div>
<label>Private Key (.p8 file)</label>
<input type="file" id="p8-input" accept=".p8">
<p id="p8-status" style="font-size:12px;color:var(--text-muted);margin-bottom:12px"></p>
<div class="btn-row">
<button type="submit">Save</button>
<button type="button" id="test-asc" class="btn-secondary">Test Connection</button>
<button type="submit">Save Account</button>
</div>
<p style="font-size:12px;color:var(--text-muted);margin-top:8px">After saving, use the Upload .p8 button in the table above.</p>
</form>
</div>
</div>
<div class="section">
<h2>unraid App Store</h2>
<h2>Storefront</h2>
<p style="font-size:13px;color:var(--text-muted);margin-bottom:12px">Where built IPAs get uploaded for OTA distribution.</p>
<div class="card">
<form id="unraid-form">
<form id="storefront-form">
<label>Base URL</label>
<input type="url" name="unraid_url" placeholder="https://appstore.treytartt.com">
<label>API Token</label>
<input type="password" name="unraid_token" placeholder="API token from unraid .env">
<input type="password" name="unraid_token" placeholder="API token from storefront .env">
<div class="btn-row">
<button type="submit">Save</button>
<button type="button" id="test-unraid" class="btn-secondary">Test Connection</button>
<button type="button" id="test-storefront" class="btn-secondary">Test Connection</button>
</div>
</form>
</div>

2686
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -8,14 +8,15 @@
"dev": "node --watch src/server.js"
},
"dependencies": {
"bcrypt": "^5.1.1",
"better-sqlite3": "^11.7.0",
"bplist-parser": "^0.3.2",
"express": "^4.21.0",
"express-session": "^1.18.1",
"multer": "^1.4.5-lts.1",
"plist": "^3.1.0",
"sharp": "^0.33.5",
"unzipper": "^0.12.3",
"bcrypt": "^5.1.1",
"uuid": "^10.0.0",
"sharp": "^0.33.5"
"uuid": "^10.0.0"
}
}

View File

@@ -1,9 +1,23 @@
const unzipper = require('unzipper');
const plist = require('plist');
const bplist = require('bplist-parser');
const fs = require('fs');
const path = require('path');
const sharp = require('sharp');
async function parsePlist(buffer) {
// Try XML plist first
try {
return plist.parse(buffer.toString('utf-8'));
} catch {}
// Try binary plist
const parsed = bplist.parseBuffer(buffer);
if (parsed && parsed.length > 0) return parsed[0];
throw new Error('Could not parse plist (neither XML nor binary)');
}
async function parseIPA(ipaPath, outputDir) {
const directory = await unzipper.Open.file(ipaPath);
@@ -17,13 +31,7 @@ async function parseIPA(ipaPath, outputDir) {
}
const plistBuffer = await infoPlistEntry.buffer();
let info;
try {
info = plist.parse(plistBuffer.toString('utf-8'));
} catch {
// Binary plist — try to parse differently
throw new Error('Binary plist detected. Please ensure IPA contains XML plist.');
}
const info = await parsePlist(plistBuffer);
const metadata = {
bundleId: info.CFBundleIdentifier,