Files
Waters ChurchandClaude Opus 5 469db5bc60 Bring the checklist app under version control
The project has lived only as a folder on the Mac Studio and, briefly, as a
transfer tarball. This is the first commit: the Express server and its web
front end, the Capacitor iOS and Android wrappers, and the release and backup
scripts.

The repo is the source, not a backup of the machine. Left out on purpose, by
the .gitignore files that were already in the tree: node_modules, build output,
mobile/www (regenerated from server/public on every build), and server/data,
which holds the live checklists and belongs on the NAS rather than in git.

Every credential is excluded too — server/.env, the Play upload keystore and
its properties file, the App Store Connect key and ios-signing.config. The one
secret that was not in an ignored file was the NAS SSH password, hard-coded in
build-deploy.sh and grepped back out of it by backup-to-drive.sh. Both now read
it from the login keychain (service checklist-nas, account Christian), falling
back to NAS_PASS in the environment, so rotating it is one keychain entry
rather than a history rewrite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 13:00:57 -04:00

162 lines
7.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
'use strict';
// asc-status.js — ask App Store Connect what it actually thinks of our builds.
//
// node scripts/asc-status.js
//
// TestFlight's web UI is vague while a build is in flight ("No Builds Available" on a tester
// row can mean processing, or export-compliance unanswered, or the build was rejected). This
// asks the API instead and prints the real processingState, so you know whether to wait or
// to go fix something.
//
// Reuses the same key release.sh uploads with. The App Store Connect API wants a short-lived
// ES256 JWT; Node can sign that with its built-in crypto, so there is no dependency here.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const MOBILE = path.resolve(__dirname, '..');
const CFG = path.join(MOBILE, 'ios-signing.config');
function cfgValue(key) {
const m = fs.readFileSync(CFG, 'utf8').match(new RegExp('^' + key + '=(.*)$', 'm'));
return m ? m[1].trim() : '';
}
const KEY_ID = cfgValue('ASC_KEY_ID');
const ISSUER_ID = cfgValue('ASC_ISSUER_ID');
const BUNDLE_ID = JSON.parse(fs.readFileSync(path.join(MOBILE, 'capacitor.config.json'), 'utf8')).appId;
const KEY_PATH = path.join(process.env.HOME, '.appstoreconnect', 'private_keys', `AuthKey_${KEY_ID}.p8`);
if (!KEY_ID || !ISSUER_ID) { console.error('✗ ASC_KEY_ID / ASC_ISSUER_ID not set in ios-signing.config'); process.exit(1); }
if (!fs.existsSync(KEY_PATH)) { console.error(`✗ missing ${KEY_PATH}`); process.exit(1); }
function b64url(buf) {
return Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function makeJwt() {
const now = Math.floor(Date.now() / 1000);
const header = { alg: 'ES256', kid: KEY_ID, typ: 'JWT' };
// Apple caps token lifetime at 20 minutes and rejects anything longer outright.
const payload = { iss: ISSUER_ID, iat: now, exp: now + 600, aud: 'appstoreconnect-v1' };
const signingInput = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`;
// dsaEncoding ieee-p1363 gives the raw r||s signature JWT requires. Node's default is DER,
// which Apple rejects with a bare 401 and no explanation — an easy hour to lose.
const sig = crypto.sign(null, Buffer.from(signingInput), {
key: fs.readFileSync(KEY_PATH),
dsaEncoding: 'ieee-p1363',
});
return `${signingInput}.${b64url(sig)}`;
}
async function api(pathname) {
const res = await fetch(`https://api.appstoreconnect.apple.com${pathname}`, {
headers: { Authorization: `Bearer ${makeJwt()}` },
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
const detail = (body.errors || []).map((e) => `${e.title}: ${e.detail}`).join('\n ') || res.statusText;
throw new Error(`HTTP ${res.status}\n ${detail}`);
}
return body;
}
(async () => {
const apps = await api(`/v1/apps?filter[bundleId]=${encodeURIComponent(BUNDLE_ID)}`);
if (!apps.data || !apps.data.length) {
console.error(`✗ no app in App Store Connect with bundle id ${BUNDLE_ID}`);
process.exit(1);
}
const app = apps.data[0];
console.log(` ${app.attributes.name} (${BUNDLE_ID})`);
console.log(` app id ${app.id}\n`);
const builds = await api(`/v1/builds?filter[app]=${app.id}&limit=10&sort=-uploadedDate`);
if (!builds.data.length) {
console.log(' No builds yet. If you just uploaded, Apple can take a few minutes to even');
console.log(' register it — re-run shortly.');
return;
}
console.log(' BUILD STATE UPLOADED');
console.log(' ───── ────────── ────────────────────');
for (const b of builds.data) {
const a = b.attributes;
console.log(` ${String(a.version).padEnd(7)} ${String(a.processingState).padEnd(12)} ${new Date(a.uploadedDate).toLocaleString()}`);
}
// processingState only says whether Apple finished ingesting the binary. Whether a TESTER
// can actually install it is a different field entirely — buildBetaDetail.internalBuildState
// — and it is the one that explains "No Builds Available".
const latestId = builds.data[0].id;
try {
const detail = await api(`/v1/builds/${latestId}/buildBetaDetail`);
const st = detail.data && detail.data.attributes;
if (st) {
console.log(` internal state ${st.internalBuildState}`);
console.log(` external state ${st.externalBuildState}`);
console.log(` auto-notify ${st.autoNotifyEnabled}`);
}
} catch (e) { console.log(` (buildBetaDetail unavailable: ${e.message.split('\n')[0]})`); }
// Groups, and which builds are actually attached to each. /v1/builds/{id}/betaGroups
// 403s for an App Manager key, but the app-scoped route works — use that one.
console.log('\n GROUPS');
try {
const groups = await api(`/v1/apps/${app.id}/betaGroups`);
for (const g of groups.data) {
const a = g.attributes;
console.log(` "${a.name}" internal=${a.isInternalGroup} allBuilds=${a.hasAccessToAllBuilds}`);
try {
const gb = await api(`/v1/betaGroups/${g.id}/builds`);
console.log(` builds: ${gb.data.length ? gb.data.map((x) => x.attributes.version).join(', ') : 'NONE — testers in this group see nothing'}`);
} catch (_) { /* non-fatal */ }
}
} catch (e) { console.log(` (unavailable: ${e.message.split('\n')[0]})`); }
// The one that cost us an afternoon. A tester sitting at NOT_INVITED was never actually
// sent an invitation — adding someone to a group does NOT invite them. TestFlight then
// shows that Apple ID its "enter an invite code" empty screen, which looks for all the
// world like the build is broken or still processing. It is neither.
console.log('\n TESTERS');
try {
const testers = await api(`/v1/betaTesters?filter[apps]=${app.id}&limit=50`);
if (!testers.data.length) console.log(' (none)');
for (const t of testers.data) {
const a = t.attributes;
const note = a.state === 'NOT_INVITED' ? ' ← never invited; they will see the invite-code screen' : '';
console.log(` ${a.email} ${a.state}${note}`);
}
} catch (e) { console.log(` (unavailable: ${e.message.split('\n')[0]})`); }
const latest = builds.data[0].attributes;
console.log();
switch (latest.processingState) {
case 'PROCESSING':
console.log(' Still processing. Testers see "No Builds Available" until this finishes —');
console.log(' usually 5–30 minutes. Nothing to fix; just wait and re-run this.');
break;
case 'VALID':
console.log(' ✓ Build is ready. If a tester still sees "No Builds Available" or an');
console.log(' invite-code screen, read the TESTERS block above — in order of likelihood:');
console.log(' NOT_INVITED → they were never sent an invite. Adding someone to a');
console.log(' group does not invite them. Resend it from TestFlight,');
console.log(' or POST /v1/betaTesterInvitations.');
console.log(' INVITED → invite sent, not yet accepted. Open the email ON THE');
console.log(' PHONE and tap "View in TestFlight".');
console.log(' groups: NONE → attach the build: TestFlight → group → Builds → +');
console.log(' And check the phone\'s App Store Apple ID matches the tester email.');
break;
case 'FAILED':
case 'INVALID':
console.log(' ✗ Apple rejected this build during processing. The reason is emailed to the');
console.log(' account holder, and shown under TestFlight → Builds. Fix, then re-run');
console.log(' ./release.sh --ios (it will use a fresh build number automatically).');
break;
default:
console.log(` Unrecognised state "${latest.processingState}" — check TestFlight directly.`);
}
})().catch((e) => { console.error(`✗ ${e.message}`); process.exit(1); });