84 lines
5.6 KiB
TypeScript
84 lines
5.6 KiB
TypeScript
// App Review information on the editable version: contact, demo account, notes. Idempotent.
|
|
// storebound apple review-details [--platform ios|macos|tvos|visionos] [--contact-from <Apple ID of another app>] [--dry]
|
|
// The contact (name, phone, email) is personal data, so it is never in the config: it comes from
|
|
// .storebound/credentials.json "reviewContact", or is copied from another app of the same team with --contact-from.
|
|
// Demo account (reviewAccess.signInRequired: true): "reviewDemoAccount" in .storebound/credentials.json or
|
|
// REVIEW_DEMO_USER / REVIEW_DEMO_PASSWORD; masked in every log line. Notes: apple.info.reviewNotes plus
|
|
// reviewAccess.instructions and accounts.inApp (lib/review-details.ts). --platform macos: apple.macos.reviewNotes
|
|
// replaces apple.info.reviewNotes when set (a Mac app may have no Game Center, IAP or ads).
|
|
import { refuseTemplate } from '../lib/template-answers';
|
|
import type { Ctx } from '../lib/ctx';
|
|
import { need } from '../lib/ctx';
|
|
import { findReviewContact, maskSecret, maskUser, noContactMessage, reviewDemoAccount } from '../lib/credentials';
|
|
import { reviewDetailPlan } from '../lib/review-details';
|
|
import { done, log } from '../lib/log';
|
|
import { createOnce } from '../lib/upsert';
|
|
import { ascClient, EDITABLE, type J } from './asc';
|
|
import { allVersions, parsePlatform, pickPlatform, platformLabel } from './platform';
|
|
|
|
type Attrs = Record<string, unknown>;
|
|
const shown = (k: string, v: unknown): string => {
|
|
if (v === null || v === undefined || v === '') return '(empty)';
|
|
const t = String(v);
|
|
if (k === 'demoAccountPassword') return maskSecret(t);
|
|
if (k === 'demoAccountName' || k === 'contactEmail') return maskUser(t);
|
|
if (k === 'contactPhone') return `${t.slice(0, 3)}***`;
|
|
if (k === 'notes') return `${new TextEncoder().encode(t).length} bytes`;
|
|
return t;
|
|
};
|
|
|
|
/** Changed review-detail keys as `key: old -> new` lines (secrets, phone, email masked; notes as byte counts). A missing
|
|
* detail (cur null) lists every key as new. Pure. */
|
|
export function reviewDetailDiff(cur: Attrs | null, want: Attrs): string[] {
|
|
return Object.entries(want).filter(([k, v]) => !cur || (cur[k] ?? null) !== (v ?? null))
|
|
.map(([k, v]) => `${k}: ${cur ? shown(k, cur[k]) : '(none)'} -> ${shown(k, v)}`);
|
|
}
|
|
|
|
export async function run(ctx: Ctx): Promise<void> {
|
|
const { config, root } = ctx.loaded;
|
|
refuseTemplate(config, ['reviewNotes', 'reviewAccess', 'accounts'], 'apple review-details (App Review notes, demo account requirement)');
|
|
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
|
const appId = await c.app();
|
|
const platform = parsePlatform(ctx.args.get('platform')) ?? pickPlatform(await allVersions(c, appId), (x: J) => EDITABLE.has(x.attributes.appVersionState ?? x.attributes.appStoreState), undefined, 'an editable version');
|
|
const version = await c.editableVersion(platform);
|
|
if (!version) throw new Error(`no editable ${platformLabel(platform)} App Store version (apple listing --version x.y.z creates it)`);
|
|
|
|
let contact: Record<string, string> | null = null;
|
|
const from = ctx.args.get('contact-from');
|
|
if (from) {
|
|
for (const v of await c.all(`/v1/apps/${from}/appStoreVersions?limit=10`)) {
|
|
const a = (await c.maybe(`/v1/appStoreVersions/${v.id}/appStoreReviewDetail`))?.data?.attributes;
|
|
if (a?.contactEmail) { contact = { contactFirstName: a.contactFirstName, contactLastName: a.contactLastName, contactPhone: a.contactPhone, contactEmail: a.contactEmail }; break; }
|
|
}
|
|
if (!contact) throw new Error(`app ${from} has no review contact on its recent versions`);
|
|
}
|
|
let checked: string[] = [];
|
|
if (!from) {
|
|
const found = findReviewContact(root);
|
|
checked = found.checked;
|
|
const rc = found.contact;
|
|
if (rc) contact = { contactFirstName: rc.firstName, contactLastName: rc.lastName, contactPhone: rc.phone, contactEmail: rc.email };
|
|
}
|
|
if (!contact) throw new Error(noContactMessage(root, checked));
|
|
|
|
const { attributes, summary } = reviewDetailPlan(config, contact, reviewDemoAccount(root), platform);
|
|
const read = async () => (await c.maybe(`/v1/appStoreVersions/${version.id}/appStoreReviewDetail`))?.data ?? null;
|
|
const cur = await read();
|
|
if (ctx.args.has('dry')) {
|
|
const lines = reviewDetailDiff(cur?.attributes ?? null, attributes);
|
|
log('apple', `would ${cur ? (lines.length ? 'update' : 'keep') : 'create'} review details (contact ${from ? 'copied from ' + from : 'from credentials'}, ${summary})`);
|
|
for (const l of lines) console.log(` ${l}`);
|
|
return;
|
|
}
|
|
// one review detail per version: only changed fields are sent; a new one is created once (a 409 finds it)
|
|
const changed = cur ? Object.fromEntries(Object.entries(attributes).filter(([k, v]) => (cur.attributes?.[k] ?? null) !== (v ?? null))) : attributes;
|
|
if (cur && !Object.keys(changed).length) { done('apple', `review details on ${version.attributes.versionString} up to date (${summary})`); return; }
|
|
if (cur) await c.asc('PATCH', `/v1/appStoreReviewDetails/${cur.id}`, { data: { type: 'appStoreReviewDetails', id: cur.id, attributes: changed } });
|
|
else {
|
|
const r = await createOnce({ what: 'App Review details', tag: 'apple', unique: true, find: read,
|
|
create: async () => (await c.asc('POST', '/v1/appStoreReviewDetails', { data: { type: 'appStoreReviewDetails', attributes,
|
|
relationships: { appStoreVersion: { data: { type: 'appStoreVersions', id: version.id } } } } })).data });
|
|
if (!r.created) await c.asc('PATCH', `/v1/appStoreReviewDetails/${r.value.id}`, { data: { type: 'appStoreReviewDetails', id: r.value.id, attributes } });
|
|
}
|
|
done('apple', `review details set on ${version.attributes.versionString} (${summary})`);
|
|
}
|