[object Object]

← back to Homesonspec

add App Store review completion poller

672b968924f9ade6f799fec58e773e6beb55ee86 · 2026-09-04 11:26:47 -0700 · Steve

Files touched

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 →