apple: no app info on App Store Connect is a clear error (it crashed apple listing / appinfo with "undefined is not an object (evaluating 'info.id')"); asset uploads use basename (a Windows path sent the whole C:\... path as fileName), reviews send --only and previews names split both separators; tests/matrix-bugs.test.ts
This commit is contained in:
parent
f58e192001
commit
2c14e5318a
5 changed files with 60 additions and 11 deletions
|
|
@ -2,6 +2,7 @@
|
|||
// API; role Admin or App Manager), paging, retries, the asset upload protocol, and the app / version lookups.
|
||||
import { createHash, createPrivateKey, sign } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { basename } from 'node:path';
|
||||
import { ascKey } from '../lib/credentials';
|
||||
import { errorBody } from '../lib/http';
|
||||
import { noteAsc } from '../lib/quota';
|
||||
|
|
@ -55,6 +56,10 @@ export function ascJwt(root: string): { jwt: () => string; keyId: string } {
|
|||
return { jwt, keyId: k.keyId };
|
||||
}
|
||||
|
||||
/** App Store Connect answered no appInfos for the app (an app record always has one): a clear error, not a TypeError. */
|
||||
export const noAppInfo = (appId: string): string =>
|
||||
`App Store Connect returned no app info for app ${appId}: the app record is not readable with this key (check apple.appId and the key's app access in Users and Access), or the record was just created and is not listed yet (run it again in a minute)`;
|
||||
|
||||
export async function ascClient(root: string, bundleId: string, appIdHint?: string) {
|
||||
const k = ascJwt(root);
|
||||
const jwt = k.jwt;
|
||||
|
|
@ -141,15 +146,19 @@ export async function ascClient(root: string, bundleId: string, appIdHint?: stri
|
|||
}
|
||||
|
||||
async function editableAppInfo(): Promise<J> {
|
||||
const infos = await all(`/v1/apps/${await app()}/appInfos`);
|
||||
return infos.find(i => !['READY_FOR_DISTRIBUTION', 'REPLACED_WITH_NEW_INFO'].includes(i.attributes.state ?? i.attributes.appStoreState)) ?? infos[0];
|
||||
const id = await app();
|
||||
const infos = await all(`/v1/apps/${id}/appInfos`);
|
||||
const info = infos.find(i => !['READY_FOR_DISTRIBUTION', 'REPLACED_WITH_NEW_INFO'].includes(i.attributes.state ?? i.attributes.appStoreState)) ?? infos[0];
|
||||
if (!info) throw new Error(noAppInfo(id));
|
||||
return info;
|
||||
}
|
||||
|
||||
/** Reserve -> PUT the parts -> commit with the MD5 (screenshots, IAP review screenshots, app previews). `attrs` go into
|
||||
* the reservation (app previews: mimeType, previewFrameTimeCode). */
|
||||
async function uploadAsset(type: string, rel: Record<string, unknown>, file: string, attrs: Record<string, unknown> = {}): Promise<string> {
|
||||
const buf = readFileSync(file);
|
||||
const name = file.split('/').pop()!;
|
||||
// basename, not split('/'): a Windows path has backslashes, and the store would get the whole path as the file name
|
||||
const name = basename(file);
|
||||
const res = (await asc('POST', `/v1/${type}`, { data: { type, attributes: { fileName: name, fileSize: buf.length, ...attrs }, relationships: rel } })).data;
|
||||
for (const op of res.attributes.uploadOperations as J[]) {
|
||||
const headers: Record<string, string> = {};
|
||||
|
|
|
|||
|
|
@ -22,7 +22,8 @@ import { appleName } from '../lib/config';
|
|||
import type { Ctx } from '../lib/ctx';
|
||||
import { confirm, need } from '../lib/ctx';
|
||||
import { done, log, printJson, warn } from '../lib/log';
|
||||
import { ascClient, EDITABLE, type J } from './asc';
|
||||
import { ascClient, EDITABLE, type J, noAppInfo } from './asc';
|
||||
import { stdinIsTTY } from '../lib/tty';
|
||||
|
||||
export function readAppleCopy(ctx: Ctx) {
|
||||
const { config, resolve } = ctx.loaded;
|
||||
|
|
@ -48,6 +49,7 @@ async function pull(ctx: Ctx): Promise<void> {
|
|||
const info = ['READY_FOR_DISTRIBUTION', 'READY_FOR_SALE'].includes(live)
|
||||
? infos.find((i: J) => ['READY_FOR_DISTRIBUTION', 'READY_FOR_SALE'].includes(i.attributes.state ?? i.attributes.appStoreState)) ?? infos[0]
|
||||
: await c.editableAppInfo();
|
||||
if (!info) throw new Error(noAppInfo(appId));
|
||||
const remote: Record<string, AppleCopy> = {};
|
||||
for (const l of await c.all(`/v1/appInfos/${info.id}/appInfoLocalizations?limit=200`)) remote[l.attributes.locale] = { name: l.attributes.name ?? undefined, subtitle: l.attributes.subtitle ?? undefined };
|
||||
for (const l of await c.all(`/v1/appStoreVersions/${v.id}/appStoreVersionLocalizations?limit=200`)) {
|
||||
|
|
@ -191,7 +193,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (refused.length) { if (dry) { process.exitCode = 1; warn('apple', `dry run: would refuse: ${refused.join('; ')}`); return; } throw new Error(refused.join('; ')); }
|
||||
if (dry) { done('apple', `dry run: ${p.changes ? `${p.changes} change(s)` : 'nothing to change'}; read-only, nothing written`); return; }
|
||||
if (!p.changes) { done('apple', 'listing already up to date'); return; }
|
||||
if (process.stdin.isTTY && !(await confirm(ctx, `Apply these ${p.changes} change(s) to App Store Connect?`))) { warn('apple', 'nothing written'); return; }
|
||||
if (stdinIsTTY() && !(await confirm(ctx, `Apply these ${p.changes} change(s) to App Store Connect?`))) { warn('apple', 'nothing written'); return; }
|
||||
|
||||
const vp = p.versionPlan;
|
||||
let versionId = vp.id;
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
try { c = await ascClient(root, bundleId, config.apple?.appId); }
|
||||
catch (e) {
|
||||
if (!dry) throw e;
|
||||
for (const [loc, per] of local) for (const [, type, fs] of per) log('apple', `${loc} ${type}: would sync ${fs.length} (${fs.map(f => f.split('/').pop()).join(', ')})`);
|
||||
for (const [loc, per] of local) for (const [, type, fs] of per) log('apple', `${loc} ${type}: would sync ${fs.length} (${fs.map(f => f.split(/[\\/]/).pop()).join(', ')})`);
|
||||
return done('apple', `previews planned (dry run, no App Store Connect comparison: ${(e as Error).message.split('\n')[0]})`);
|
||||
}
|
||||
const version = await c.editableVersion();
|
||||
|
|
|
|||
|
|
@ -126,8 +126,8 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const drafts = only === undefined ? all : all.filter(d => d.file.split(/[\\/]/).pop() === only);
|
||||
if (only !== undefined && !drafts.length) throw new Error(`no draft ${only} in ${dir}`);
|
||||
const plan = sendPlan(drafts);
|
||||
for (const s of plan.skip) log(TAG, `skip ${s.file.split('/').pop()}: ${s.why}`);
|
||||
for (const d of plan.send) for (const i of checkReply(d.reply, d.store)) warn(TAG, `${d.file.split('/').pop()}: ${i.msg}`);
|
||||
for (const s of plan.skip) log(TAG, `skip ${s.file.split(/[\\/]/).pop()}: ${s.why}`);
|
||||
for (const d of plan.send) for (const i of checkReply(d.reply, d.store)) warn(TAG, `${d.file.split(/[\\/]/).pop()}: ${i.msg}`);
|
||||
if (!plan.send.length) { done(TAG, 'nothing to send'); return; }
|
||||
console.log(textTable(['store', 'review', 'chars', 'reply'], plan.send.map(d => [d.store, d.reviewId, [...d.reply].length, d.reply.replace(/\s+/g, ' ').slice(0, 70)])));
|
||||
if (a.has('dry')) { done(TAG, `${plan.send.length} reply(ies) would be sent (dry run)`); return; }
|
||||
|
|
@ -144,13 +144,13 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (d.store === 'apple') {
|
||||
try { await c!.asc(req.method, req.url, req.body); } catch (e) {
|
||||
if (!isConflict(e)) throw e;
|
||||
warn(TAG, `${d.file.split('/').pop()}: the review already has a response on the App Store (an earlier run sent it); noted as sent, not sent again`);
|
||||
warn(TAG, `${d.file.split(/[\\/]/).pop()}: the review already has a response on the App Store (an earlier run sent it); noted as sent, not sent again`);
|
||||
}
|
||||
} else await play!.call(req.method, req.url, req.body);
|
||||
writeFileSync(d.file, markSent(texts.get(d.file)!, new Date().toISOString()));
|
||||
sent++;
|
||||
log(TAG, `sent ${d.file.split('/').pop()}`);
|
||||
} catch (e) { warn(TAG, `${d.file.split('/').pop()}: ${(e as Error).message.slice(0, 300)}`); }
|
||||
log(TAG, `sent ${d.file.split(/[\\/]/).pop()}`);
|
||||
} catch (e) { warn(TAG, `${d.file.split(/[\\/]/).pop()}: ${(e as Error).message.slice(0, 300)}`); }
|
||||
}
|
||||
done(TAG, `${sent}/${plan.send.length} sent (Apple shows replies within about 24 hours)`);
|
||||
return;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue