← back to Homesonspec
add App Store review completion poller
672b968924f9ade6f799fec58e773e6beb55ee86 · 2026-09-04 11:26:47 -0700 · Steve
Files touched
M .gitignoreA ops/apple-review-poller.mjsA ops/apple-review-poller.test.mjsA ops/com.steve.homesonspec-apple-review.plist
Diff
commit 672b968924f9ade6f799fec58e773e6beb55ee86
Author: Steve <steve@designerwallcoverings.com>
Date: Fri Sep 4 11:26:47 2026 -0700
add App Store review completion poller
---
.gitignore | 1 +
ops/apple-review-poller.mjs | 117 +++++++++++++++++++++++++++
ops/apple-review-poller.test.mjs | 39 +++++++++
ops/com.steve.homesonspec-apple-review.plist | 23 ++++++
4 files changed, 180 insertions(+)
diff --git a/.gitignore b/.gitignore
index 17ef0429..65c6ba18 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,3 +32,4 @@ ops/.import-sweep.lock
# snapshot-integrity canary heartbeat (runtime output)
ops/data/
+ops/runtime/
diff --git a/ops/apple-review-poller.mjs b/ops/apple-review-poller.mjs
new file mode 100644
index 00000000..1dfb1d13
--- /dev/null
+++ b/ops/apple-review-poller.mjs
@@ -0,0 +1,117 @@
+#!/usr/bin/env node
+
+import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { spawnSync } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+
+export const APP_NAME = 'Homes on Spec';
+export const JOB_LABEL = 'com.steve.homesonspec-apple-review';
+
+const TERMINAL_STATES = new Set([
+ 'READY_FOR_SALE',
+ 'READY_FOR_DISTRIBUTION',
+ 'PENDING_DEVELOPER_RELEASE',
+ 'REJECTED',
+ 'METADATA_REJECTED',
+ 'DEVELOPER_REJECTED',
+ 'INVALID_BINARY',
+ 'REMOVED_FROM_SALE',
+ 'DEVELOPER_REMOVED_FROM_SALE',
+]);
+
+export function parseAppState(output, appName = APP_NAME) {
+ const escaped = appName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const match = output.match(new RegExp(`${escaped}\\s+([A-Z][A-Z0-9_]+)\\s+\\S+`, 'm'));
+ return match?.[1] ?? null;
+}
+
+export function classifyState(state) {
+ if (!state) return { terminal: false, outcome: 'unknown' };
+ if (!TERMINAL_STATES.has(state)) return { terminal: false, outcome: 'pending' };
+ if (['READY_FOR_SALE', 'READY_FOR_DISTRIBUTION', 'PENDING_DEVELOPER_RELEASE'].includes(state)) {
+ return { terminal: true, outcome: 'approved' };
+ }
+ return { terminal: true, outcome: 'attention' };
+}
+
+function record(path, event) {
+ mkdirSync(dirname(path), { recursive: true });
+ appendFileSync(path, `${JSON.stringify(event)}\n`);
+}
+
+function notify(state, outcome) {
+ const message = outcome === 'approved'
+ ? `Apple completed review: ${state}`
+ : `Apple review needs attention: ${state}`;
+ spawnSync('/usr/bin/osascript', [
+ '-e', 'display notification ' + JSON.stringify(message) + ' with title "Homes on Spec"',
+ ]);
+}
+
+function removeScheduler() {
+ return spawnSync('/bin/launchctl', ['remove', JOB_LABEL], { encoding: 'utf8' });
+}
+
+export function run({ fixturePath = null, runtimeDir = resolve('ops/runtime') } = {}) {
+ const checkedAt = new Date().toISOString();
+ const logPath = resolve(runtimeDir, 'apple-review-poller.jsonl');
+ const statePath = resolve(runtimeDir, 'apple-review-status.json');
+
+ let output;
+ let commandStatus = 0;
+ let commandError = '';
+ if (fixturePath) {
+ output = readFileSync(fixturePath, 'utf8');
+ } else {
+ const tool = process.env.IPA_STATUS_PATH
+ ?? '/Users/macstudio3/.agents/skills/ipa-status/scripts/ipa-status.mjs';
+ const result = spawnSync(process.execPath, [tool], { encoding: 'utf8', timeout: 120_000 });
+ output = result.stdout ?? '';
+ commandStatus = result.status ?? 1;
+ commandError = (result.stderr ?? '').trim();
+ }
+
+ const state = commandStatus === 0 ? parseAppState(output) : null;
+ const classification = classifyState(state);
+ const event = {
+ checkedAt,
+ app: APP_NAME,
+ state,
+ ...classification,
+ commandStatus,
+ error: commandError || (state ? null : 'App state missing from ipa-status output'),
+ };
+
+ record(logPath, event);
+ mkdirSync(dirname(statePath), { recursive: true });
+ writeFileSync(statePath, `${JSON.stringify(event, null, 2)}\n`);
+ process.stdout.write(`${JSON.stringify(event)}\n`);
+
+ if (classification.terminal && !fixturePath) {
+ notify(state, classification.outcome);
+ const removal = removeScheduler();
+ record(logPath, {
+ checkedAt: new Date().toISOString(),
+ event: 'scheduler-removal',
+ label: JOB_LABEL,
+ status: removal.status,
+ error: (removal.stderr ?? '').trim() || null,
+ });
+ }
+
+ return event;
+}
+
+const isMain = process.argv[1]
+ && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
+
+if (isMain) {
+ const fixtureIndex = process.argv.indexOf('--fixture');
+ const runtimeIndex = process.argv.indexOf('--runtime-dir');
+ const event = run({
+ fixturePath: fixtureIndex >= 0 ? process.argv[fixtureIndex + 1] : null,
+ runtimeDir: runtimeIndex >= 0 ? process.argv[runtimeIndex + 1] : undefined,
+ });
+ process.exitCode = event.commandStatus === 0 && event.state ? 0 : 1;
+}
diff --git a/ops/apple-review-poller.test.mjs b/ops/apple-review-poller.test.mjs
new file mode 100644
index 00000000..603e0258
--- /dev/null
+++ b/ops/apple-review-poller.test.mjs
@@ -0,0 +1,39 @@
+import assert from 'node:assert/strict';
+import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import test from 'node:test';
+
+import { classifyState, parseAppState, run } from './apple-review-poller.mjs';
+
+const row = (state) => `🟡 Homes on Spec ${state} 1.0\n`;
+
+test('extracts Homes on Spec without confusing other apps', () => {
+ const output = `🔴 Another App REJECTED 1.0\n${row('WAITING_FOR_REVIEW')}`;
+ assert.equal(parseAppState(output), 'WAITING_FOR_REVIEW');
+});
+
+test('classifies pending, approved, and rejected review outcomes', () => {
+ assert.deepEqual(classifyState('WAITING_FOR_REVIEW'), { terminal: false, outcome: 'pending' });
+ assert.deepEqual(classifyState('IN_REVIEW'), { terminal: false, outcome: 'pending' });
+ assert.deepEqual(classifyState('PENDING_DEVELOPER_RELEASE'), { terminal: true, outcome: 'approved' });
+ assert.deepEqual(classifyState('READY_FOR_SALE'), { terminal: true, outcome: 'approved' });
+ assert.deepEqual(classifyState('REJECTED'), { terminal: true, outcome: 'attention' });
+});
+
+test('fixture run persists an auditable status and does not remove launchd job', () => {
+ const dir = mkdtempSync(join(tmpdir(), 'homesonspec-review-poller-'));
+ const fixture = join(dir, 'status.txt');
+ writeFileSync(fixture, row('READY_FOR_SALE'));
+ const event = run({ fixturePath: fixture, runtimeDir: dir });
+ assert.equal(event.terminal, true);
+ assert.equal(event.outcome, 'approved');
+ const saved = JSON.parse(readFileSync(join(dir, 'apple-review-status.json'), 'utf8'));
+ assert.equal(saved.state, 'READY_FOR_SALE');
+ assert.match(readFileSync(join(dir, 'apple-review-poller.jsonl'), 'utf8'), /"terminal":true/);
+});
+
+test('missing app is a retryable error, never a false terminal result', () => {
+ assert.equal(parseAppState('⚪ Different App PREPARE_FOR_SUBMISSION 1.0'), null);
+ assert.deepEqual(classifyState(null), { terminal: false, outcome: 'unknown' });
+});
diff --git a/ops/com.steve.homesonspec-apple-review.plist b/ops/com.steve.homesonspec-apple-review.plist
new file mode 100644
index 00000000..76f28228
--- /dev/null
+++ b/ops/com.steve.homesonspec-apple-review.plist
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>Label</key>
+ <string>com.steve.homesonspec-apple-review</string>
+ <key>ProgramArguments</key>
+ <array>
+ <string>/opt/homebrew/bin/node</string>
+ <string>/Users/macstudio3/Projects/homesonspec/ops/apple-review-poller.mjs</string>
+ </array>
+ <key>WorkingDirectory</key>
+ <string>/Users/macstudio3/Projects/homesonspec</string>
+ <key>RunAtLoad</key>
+ <true/>
+ <key>StartInterval</key>
+ <integer>900</integer>
+ <key>StandardOutPath</key>
+ <string>/Users/macstudio3/Projects/homesonspec/ops/runtime/apple-review-launchd.log</string>
+ <key>StandardErrorPath</key>
+ <string>/Users/macstudio3/Projects/homesonspec/ops/runtime/apple-review-launchd.log</string>
+</dict>
+</plist>
← a656c9b9 auto-data-snapshot: 2026-09-04T10:10:38 (1 data files) — app
·
back to Homesonspec
·
verify Apple review poller lifecycle 7cf4bed8 →