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>
This commit is contained in:
Waters Church
2026-09-24 13:00:57 -04:00
co-authored by Claude Opus 5
commit 469db5bc60
177 changed files with 33247 additions and 0 deletions
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env node
'use strict';
// upload-play.js — push a signed .aab to Google Play, unattended.
//
// node scripts/upload-play.js <path-to.aab> [--track internal] [--dry-run]
//
// Why a hand-written script rather than fastlane: fastlane needs Ruby >= 2.7 and this Mac
// has 2.6 (the same wall that pushed iOS onto SPM instead of CocoaPods). The Play Developer
// API is straightforward enough to call directly, and doing so keeps the whole toolchain on
// Node, which is already here.
//
// Auth is a Google Cloud SERVICE ACCOUNT — not your Google login. The JSON key goes at
// mobile/play-service-account.json (gitignored). Setup is in README.md → Android uploads.
//
// Play's upload is a TRANSACTION: open an edit, upload into it, point a track at it, then
// commit. Nothing is visible to anyone until the commit succeeds, so a crash halfway through
// leaves an abandoned edit and changes nothing — which is exactly the behaviour you want
// from something that ships to volunteers.
const fs = require('fs');
const path = require('path');
const { androidpublisher, auth: gauth } = require('@googleapis/androidpublisher');
const MOBILE = path.resolve(__dirname, '..');
const KEY_FILE = path.join(MOBILE, 'play-service-account.json');
const CAP_CONFIG = JSON.parse(fs.readFileSync(path.join(MOBILE, 'capacitor.config.json'), 'utf8'));
const PACKAGE_NAME = CAP_CONFIG.appId;
const args = process.argv.slice(2);
const aabPath = args.find((a) => !a.startsWith('--'));
const trackIdx = args.indexOf('--track');
const track = trackIdx !== -1 ? args[trackIdx + 1] : 'internal';
const dryRun = args.includes('--dry-run');
function die(msg, hint) {
console.error(`✗ ${msg}`);
if (hint) console.error(String(hint).split('\n').map((l) => ` ${l}`).join('\n'));
process.exit(1);
}
if (!aabPath && !dryRun) die('usage: node scripts/upload-play.js <path-to.aab> [--track internal]');
if (aabPath && !fs.existsSync(aabPath)) die(`no such file: ${aabPath}`);
if (!fs.existsSync(KEY_FILE)) {
die(`no ${path.relative(process.cwd(), KEY_FILE)}`,
'This is the Google Cloud service-account JSON that lets Play accept uploads.\n' +
'One-time setup: mobile/README.md → "Android uploads (Play Developer API)".');
}
(async () => {
let creds;
try {
creds = JSON.parse(fs.readFileSync(KEY_FILE, 'utf8'));
} catch (e) {
die('play-service-account.json is not valid JSON', 'Re-download the key from Google Cloud Console.');
}
if (creds.type !== 'service_account' || !creds.client_email || !creds.private_key) {
die('that JSON is not a service-account key',
'It should contain "type": "service_account" and a client_email ending in\n' +
'.iam.gserviceaccount.com. An OAuth client ID download is a different file.');
}
const client = new gauth.GoogleAuth({
credentials: creds,
scopes: ['https://www.googleapis.com/auth/androidpublisher'],
});
const play = androidpublisher({ version: 'v3', auth: client });
console.log(` package ${PACKAGE_NAME}`);
console.log(` service acct ${creds.client_email}`);
console.log(` track ${track}`);
// Opening an edit is also the cheapest possible permission check: it fails immediately and
// specifically if the service account isn't linked to this app in the Play Console.
let editId;
try {
const edit = await play.edits.insert({ packageName: PACKAGE_NAME });
editId = edit.data.id;
} catch (e) {
const status = e && e.code;
const reason = (e && e.message) || String(e);
if (status === 401 || status === 403) {
die(`Play rejected the service account (HTTP ${status})`,
`${reason}\n\n` +
'Usually one of:\n' +
' · the service account has not been invited in Play Console →\n' +
' Users and permissions → Invite user → (the client_email above)\n' +
' · it lacks "Release to testing tracks" permission for this app\n' +
' · the Google Play Android Developer API is not enabled on the Cloud project');
}
if (status === 404) {
die(`Play has no app with package name ${PACKAGE_NAME}`,
'Create it first: Play Console → Create app, then upload one build by hand.\n' +
'Play requires the FIRST bundle of a brand-new app to be uploaded through the\n' +
'Console UI; the API can only take over from the second release onward.');
}
die(`could not open a Play edit: ${reason}`);
}
console.log(` edit ${editId}`);
if (dryRun) {
// Abandon rather than commit — proves credentials and permissions end to end while
// changing absolutely nothing.
await play.edits.delete({ packageName: PACKAGE_NAME, editId });
console.log('✓ credentials and permissions OK (dry run — edit abandoned, nothing changed)');
return;
}
console.log(` uploading ${path.basename(aabPath)} (${(fs.statSync(aabPath).size / 1048576).toFixed(1)} MB)…`);
let versionCode;
try {
const res = await play.edits.bundles.upload({
packageName: PACKAGE_NAME,
editId,
media: { mimeType: 'application/octet-stream', body: fs.createReadStream(aabPath) },
});
versionCode = res.data.versionCode;
} catch (e) {
const reason = (e && e.message) || String(e);
await play.edits.delete({ packageName: PACKAGE_NAME, editId }).catch(() => {});
if (/already.*used|not allowed to be used|APK.*version code/i.test(reason)) {
die('Play has already seen that versionCode',
`${reason}\n\n` +
'Play permanently reserves every versionCode it has ever accepted — they cannot be\n' +
'reused even after deleting the release. Bump androidVersionCode in\n' +
'mobile/release.config.json and rebuild.');
}
die(`bundle upload failed: ${reason}`);
}
console.log(` versionCode ${versionCode}`);
// Point the track at the build we just uploaded. status 'completed' means testers on that
// track get it straight away; 'draft' would park it in the Console for a human to release.
await play.edits.tracks.update({
packageName: PACKAGE_NAME,
editId,
track,
requestBody: { releases: [{ versionCodes: [String(versionCode)], status: 'completed' }] },
});
await play.edits.commit({ packageName: PACKAGE_NAME, editId });
console.log(`✓ released to the "${track}" track (versionCode ${versionCode})`);
console.log(' Play processes the bundle for a few minutes before testers see the update.');
})().catch((e) => die(String((e && e.message) || e)));