appstore-api-mcp 1.12.0 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +85 -0
- package/README.md +1 -0
- package/docs/SETUP.md +55 -0
- package/docs/TOOLS.md +124 -1
- package/package.json +1 -1
- package/src/cicd.js +481 -0
- package/src/client.js +19 -0
- package/src/guardrails.js +22 -1
- package/src/index.js +2122 -59
- package/src/ppp-countries.csv +176 -0
- package/src/ppp.js +183 -0
- package/src/subscriptions.js +45 -0
package/src/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
copyFileSync,
|
|
8
8
|
readdirSync,
|
|
9
9
|
} from "node:fs";
|
|
10
|
-
import { basename, join } from "node:path";
|
|
10
|
+
import { basename, join, dirname } from "node:path";
|
|
11
11
|
import { homedir, tmpdir } from "node:os";
|
|
12
12
|
import { execFile } from "node:child_process";
|
|
13
13
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
@@ -19,6 +19,21 @@ import {
|
|
|
19
19
|
import { AppStoreConnectClient } from "./client.js";
|
|
20
20
|
import { LIMITS, validateAttributes, buildDiff } from "./validation.js";
|
|
21
21
|
import { writeBlockReason, writeModeSummary } from "./guardrails.js";
|
|
22
|
+
import {
|
|
23
|
+
renderTemplates,
|
|
24
|
+
detectProject,
|
|
25
|
+
existingPipelineFiles,
|
|
26
|
+
parseRepoSlug,
|
|
27
|
+
} from "./cicd.js";
|
|
28
|
+
import {
|
|
29
|
+
loadCountries,
|
|
30
|
+
calculateTargetPrices,
|
|
31
|
+
findPricePoint,
|
|
32
|
+
decodeTerritory,
|
|
33
|
+
validateCoefficients,
|
|
34
|
+
tierSummary,
|
|
35
|
+
} from "./ppp.js";
|
|
36
|
+
import { detectOverlappingOffers } from "./subscriptions.js";
|
|
22
37
|
|
|
23
38
|
const client = new AppStoreConnectClient({
|
|
24
39
|
keyId: process.env.ASC_KEY_ID,
|
|
@@ -120,6 +135,746 @@ function runCmd(cmd, args, opts = {}) {
|
|
|
120
135
|
|
|
121
136
|
const tail = (s, n = 40) => (s || "").split("\n").slice(-n).join("\n");
|
|
122
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Like runCmd, but pipes `input` to the child's stdin and closes it. Used to
|
|
140
|
+
* pass secret values to `gh secret set` without exposing them in argv/ps.
|
|
141
|
+
*/
|
|
142
|
+
function runCmdStdin(cmd, args, input, opts = {}) {
|
|
143
|
+
return new Promise((resolve) => {
|
|
144
|
+
const child = execFile(
|
|
145
|
+
cmd,
|
|
146
|
+
args,
|
|
147
|
+
{
|
|
148
|
+
cwd: opts.cwd,
|
|
149
|
+
timeout: opts.timeout || 0,
|
|
150
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
151
|
+
env: process.env,
|
|
152
|
+
},
|
|
153
|
+
(err, stdout, stderr) => {
|
|
154
|
+
resolve({
|
|
155
|
+
code: err && typeof err.code === "number" ? err.code : err ? 1 : 0,
|
|
156
|
+
stdout: stdout || "",
|
|
157
|
+
stderr: stderr || "",
|
|
158
|
+
error: err ? err.message : null,
|
|
159
|
+
});
|
|
160
|
+
},
|
|
161
|
+
);
|
|
162
|
+
if (input != null && child.stdin) {
|
|
163
|
+
child.stdin.on("error", () => {});
|
|
164
|
+
child.stdin.write(input);
|
|
165
|
+
child.stdin.end();
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---- iOS CI/CD → TestFlight bootstrap helpers ----
|
|
171
|
+
|
|
172
|
+
/** Ensure the `gh` CLI is installed and authenticated; throw friendly guidance. */
|
|
173
|
+
async function ensureGh() {
|
|
174
|
+
const v = await runCmd("gh", ["--version"]);
|
|
175
|
+
if (v.code !== 0)
|
|
176
|
+
throw new Error(
|
|
177
|
+
"GitHub CLI (`gh`) not found. Install it from https://cli.github.com (e.g. `brew install gh`), then `gh auth login`.",
|
|
178
|
+
);
|
|
179
|
+
const auth = await runCmd("gh", ["auth", "status"]);
|
|
180
|
+
if (auth.code !== 0)
|
|
181
|
+
throw new Error(
|
|
182
|
+
"GitHub CLI is not authenticated. Run `gh auth login` (needs repo admin to set Actions secrets).",
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Resolve the GitHub owner/name slug for a tool call (explicit wins, else origin). */
|
|
187
|
+
async function resolveRepoSlug({ repo, owner, repoDir }) {
|
|
188
|
+
if (repo && repo.includes("/")) return repo;
|
|
189
|
+
if (repo && owner) return `${owner}/${repo}`;
|
|
190
|
+
const r = await runCmd("git", ["-C", repoDir || ".", "remote", "get-url", "origin"]);
|
|
191
|
+
const slug = r.code === 0 ? parseRepoSlug(r.stdout) : null;
|
|
192
|
+
if (repo && slug) return `${slug.split("/")[0]}/${repo}`; // repo name + origin owner
|
|
193
|
+
if (slug) return slug;
|
|
194
|
+
throw new Error(
|
|
195
|
+
"Could not determine the GitHub repo. Pass `repo` as \"owner/name\", or run from a clone with an `origin` remote.",
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Read the App Store Connect API key material from THIS server's own config
|
|
201
|
+
* (env), returning the three CI secret values. The .p8 is base64-encoded as
|
|
202
|
+
* GitHub Actions / fastlane (is_key_content_base64) expect. Never logged or
|
|
203
|
+
* returned to tool output — only handed to `gh secret set` over stdin.
|
|
204
|
+
*/
|
|
205
|
+
function ascSecretValues() {
|
|
206
|
+
const keyId = process.env.ASC_KEY_ID;
|
|
207
|
+
const issuerId = process.env.ASC_ISSUER_ID;
|
|
208
|
+
if (!keyId || !issuerId)
|
|
209
|
+
throw new Error(
|
|
210
|
+
"ASC_KEY_ID / ASC_ISSUER_ID are not set on this server. Configure the App Store Connect API key in the server's environment first.",
|
|
211
|
+
);
|
|
212
|
+
// p8 → base64, mirroring client.js precedence: base64 env, inline PEM, path.
|
|
213
|
+
let p8b64;
|
|
214
|
+
if (process.env.ASC_PRIVATE_KEY_BASE64) {
|
|
215
|
+
p8b64 = process.env.ASC_PRIVATE_KEY_BASE64.replace(/\s+/g, "");
|
|
216
|
+
} else if (process.env.ASC_PRIVATE_KEY) {
|
|
217
|
+
p8b64 = Buffer.from(process.env.ASC_PRIVATE_KEY, "utf8").toString("base64");
|
|
218
|
+
} else if (process.env.ASC_PRIVATE_KEY_PATH) {
|
|
219
|
+
p8b64 = Buffer.from(
|
|
220
|
+
readFileSync(process.env.ASC_PRIVATE_KEY_PATH, "utf8"),
|
|
221
|
+
"utf8",
|
|
222
|
+
).toString("base64");
|
|
223
|
+
} else {
|
|
224
|
+
throw new Error(
|
|
225
|
+
"No .p8 key configured. Set ASC_PRIVATE_KEY_PATH, ASC_PRIVATE_KEY, or ASC_PRIVATE_KEY_BASE64 on this server.",
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
ASC_KEY_ID: keyId,
|
|
230
|
+
ASC_ISSUER_ID: issuerId,
|
|
231
|
+
ASC_KEY_P8: p8b64,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Find the App Store Connect app record for a bundle id (find-only). */
|
|
236
|
+
async function findAppByBundleId(bundleId) {
|
|
237
|
+
const apps = await client.getAll("/apps", {
|
|
238
|
+
"filter[bundleId]": bundleId,
|
|
239
|
+
limit: 200,
|
|
240
|
+
});
|
|
241
|
+
// filter[bundleId] is a prefix-ish match on some accounts — require exact.
|
|
242
|
+
return apps.find((a) => a.attributes?.bundleId === bundleId) || null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Shared bootstrap implementation (used by bootstrap_ios_cicd and the
|
|
247
|
+
* bootstrap_testflight orchestrator). Renders the pipeline files, then commits
|
|
248
|
+
* them to a branch / opens a PR via git + gh. Returns a structured report.
|
|
249
|
+
*/
|
|
250
|
+
async function doBootstrap(a) {
|
|
251
|
+
const repoDir = a.repoDir || ".";
|
|
252
|
+
if (!existsSync(repoDir))
|
|
253
|
+
return { error: `repoDir not found: ${repoDir}` };
|
|
254
|
+
const gitCheck = await runCmd("git", ["-C", repoDir, "rev-parse", "--show-toplevel"]);
|
|
255
|
+
if (gitCheck.code !== 0)
|
|
256
|
+
return { error: `${repoDir} is not a git repository.` };
|
|
257
|
+
const repoRoot = gitCheck.stdout.trim();
|
|
258
|
+
|
|
259
|
+
const { detected, missing, warnings } = detectProject(repoRoot, {
|
|
260
|
+
appDir: a.appDir,
|
|
261
|
+
bundleId: a.bundleId,
|
|
262
|
+
teamId: a.teamId,
|
|
263
|
+
scheme: a.scheme,
|
|
264
|
+
target: a.target,
|
|
265
|
+
});
|
|
266
|
+
if (missing.length)
|
|
267
|
+
return {
|
|
268
|
+
error: `Could not auto-detect ${missing.join(", ")}. Pass them explicitly.`,
|
|
269
|
+
detected,
|
|
270
|
+
warnings,
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const files = renderTemplates(detected);
|
|
274
|
+
const existing = existingPipelineFiles(repoRoot, files);
|
|
275
|
+
|
|
276
|
+
if (a.dryRun) {
|
|
277
|
+
return {
|
|
278
|
+
dryRun: true,
|
|
279
|
+
detected,
|
|
280
|
+
warnings,
|
|
281
|
+
filesToWrite: Object.keys(files),
|
|
282
|
+
wouldOverwrite: existing,
|
|
283
|
+
files, // full rendered contents for review
|
|
284
|
+
note: "Nothing was written. Re-run without dryRun to apply.",
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Write files.
|
|
289
|
+
for (const [rel, contents] of Object.entries(files)) {
|
|
290
|
+
const abs = join(repoRoot, rel);
|
|
291
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
292
|
+
writeFileSync(abs, contents);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const mode = a.mode || "pr";
|
|
296
|
+
const written = Object.keys(files);
|
|
297
|
+
const result = { detected, warnings, filesWritten: written, overwrote: existing, mode };
|
|
298
|
+
|
|
299
|
+
if (mode === "files") {
|
|
300
|
+
result.note = "Files written to the working tree; not committed (mode: files).";
|
|
301
|
+
return result;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Commit on a branch.
|
|
305
|
+
const branch = a.branch || "ci/ios-testflight-bootstrap";
|
|
306
|
+
const base =
|
|
307
|
+
a.baseBranch ||
|
|
308
|
+
(await runCmd("git", ["-C", repoRoot, "symbolic-ref", "--quiet", "--short", "HEAD"])).stdout.trim() ||
|
|
309
|
+
"main";
|
|
310
|
+
const co = await runCmd("git", ["-C", repoRoot, "checkout", "-B", branch]);
|
|
311
|
+
if (co.code !== 0)
|
|
312
|
+
return { ...result, error: "git checkout failed", log: tail(co.stderr, 20) };
|
|
313
|
+
await runCmd("git", ["-C", repoRoot, "add", ...written]);
|
|
314
|
+
const commit = await runCmd("git", [
|
|
315
|
+
"-C", repoRoot, "commit", "-m", "ci: add fastlane + GitHub Actions TestFlight pipeline",
|
|
316
|
+
]);
|
|
317
|
+
if (commit.code !== 0)
|
|
318
|
+
return {
|
|
319
|
+
...result,
|
|
320
|
+
branch,
|
|
321
|
+
error: "git commit failed (nothing to commit, or git not configured).",
|
|
322
|
+
log: tail(commit.stdout + "\n" + commit.stderr, 20),
|
|
323
|
+
};
|
|
324
|
+
result.branch = branch;
|
|
325
|
+
|
|
326
|
+
if (mode === "commit") {
|
|
327
|
+
result.note = `Committed to branch ${branch}. Push and open a PR when ready.`;
|
|
328
|
+
return result;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// mode "branch" or "pr": push, and for "pr" open a PR.
|
|
332
|
+
await ensureGh();
|
|
333
|
+
const push = await runCmd("git", ["-C", repoRoot, "push", "-u", "origin", branch]);
|
|
334
|
+
if (push.code !== 0)
|
|
335
|
+
return { ...result, error: "git push failed", log: tail(push.stderr, 20) };
|
|
336
|
+
|
|
337
|
+
if (mode === "branch") {
|
|
338
|
+
result.note = `Pushed branch ${branch}. Open a PR when ready.`;
|
|
339
|
+
return result;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const pr = await runCmd("gh", [
|
|
343
|
+
"pr", "create",
|
|
344
|
+
"--repo", await resolveRepoSlug({ repo: a.repo, owner: a.owner, repoDir: repoRoot }),
|
|
345
|
+
"--head", branch,
|
|
346
|
+
"--base", base,
|
|
347
|
+
"--title", "Add iOS CI/CD → TestFlight pipeline",
|
|
348
|
+
"--body",
|
|
349
|
+
"Adds fastlane + two GitHub Actions workflows (simulator CI build + signed TestFlight upload via Xcode cloud signing).\n\nRequires the `ASC_KEY_ID`, `ASC_ISSUER_ID`, `ASC_KEY_P8` repo secrets — set them with `set_repo_ci_secrets` if not already present.",
|
|
350
|
+
]);
|
|
351
|
+
if (pr.code !== 0)
|
|
352
|
+
return { ...result, error: "gh pr create failed", log: tail(pr.stdout + "\n" + pr.stderr, 20) };
|
|
353
|
+
result.prUrl = pr.stdout.trim();
|
|
354
|
+
result.note = `Opened PR: ${result.prUrl}`;
|
|
355
|
+
return result;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Shared secret-pushing implementation (used by tool + orchestrator). */
|
|
359
|
+
async function doSetSecrets(a) {
|
|
360
|
+
await ensureGh();
|
|
361
|
+
const slug = await resolveRepoSlug({
|
|
362
|
+
repo: a.repo,
|
|
363
|
+
owner: a.owner,
|
|
364
|
+
repoDir: a.repoDir || ".",
|
|
365
|
+
});
|
|
366
|
+
const secrets = ascSecretValues(); // throws if server creds are missing
|
|
367
|
+
const set = [];
|
|
368
|
+
for (const [name, value] of Object.entries(secrets)) {
|
|
369
|
+
const res = await runCmdStdin("gh", ["secret", "set", name, "--repo", slug], value);
|
|
370
|
+
if (res.code !== 0)
|
|
371
|
+
return {
|
|
372
|
+
repo: slug,
|
|
373
|
+
error: `Failed to set ${name}`,
|
|
374
|
+
log: tail(res.stderr, 20),
|
|
375
|
+
secretsSet: set,
|
|
376
|
+
};
|
|
377
|
+
set.push(name); // names only — values are never returned
|
|
378
|
+
}
|
|
379
|
+
return {
|
|
380
|
+
repo: slug,
|
|
381
|
+
secretsSet: set,
|
|
382
|
+
note: "Three Actions secrets set from this server's configured API key. Values were never exposed.",
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ---- PPP (purchasing-power-parity) pricing helpers ----
|
|
387
|
+
// These operate on IAP / subscription *price points* across territories — a
|
|
388
|
+
// different surface from set_app_price (which sets the paid-app price).
|
|
389
|
+
|
|
390
|
+
const PPP_TERRITORIES_PER_REQUEST = 8; // ~800 points/territory; 8 fit one 8000-row page
|
|
391
|
+
|
|
392
|
+
/** Fetch every purchasable product (non-subscription IAPs v2 + subscriptions). */
|
|
393
|
+
async function pppFetchProducts(appId) {
|
|
394
|
+
const products = [];
|
|
395
|
+
const iaps = await client.getAll(`/apps/${appId}/inAppPurchasesV2`, { limit: 200 });
|
|
396
|
+
for (const e of iaps) {
|
|
397
|
+
const t = e.attributes?.inAppPurchaseType || "";
|
|
398
|
+
if (t === "AUTOMATICALLY_RENEWABLE_SUBSCRIPTION") continue;
|
|
399
|
+
products.push({
|
|
400
|
+
id: e.id,
|
|
401
|
+
name: e.attributes?.name || "",
|
|
402
|
+
productId: e.attributes?.productId || "",
|
|
403
|
+
productType: t,
|
|
404
|
+
isSubscription: false,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
const groups = await client.getAll(`/apps/${appId}/subscriptionGroups`);
|
|
408
|
+
for (const g of groups) {
|
|
409
|
+
const subs = await client.getAll(`/subscriptionGroups/${g.id}/subscriptions`, { limit: 200 });
|
|
410
|
+
for (const s of subs) {
|
|
411
|
+
products.push({
|
|
412
|
+
id: s.id,
|
|
413
|
+
name: s.attributes?.name || "",
|
|
414
|
+
productId: s.attributes?.productId || "",
|
|
415
|
+
productType: "AUTOMATICALLY_RENEWABLE_SUBSCRIPTION",
|
|
416
|
+
isSubscription: true,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return products;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Current US price for a product (null if none set). */
|
|
424
|
+
async function pppFetchUsPrice(product) {
|
|
425
|
+
if (product.isSubscription) {
|
|
426
|
+
const { data, included } = await client.getAllPages(`/subscriptions/${product.id}/prices`, {
|
|
427
|
+
include: "subscriptionPricePoint,territory",
|
|
428
|
+
"fields[subscriptionPricePoints]": "customerPrice",
|
|
429
|
+
limit: 200,
|
|
430
|
+
});
|
|
431
|
+
const ppMap = new Map(
|
|
432
|
+
included.filter((i) => i.type === "subscriptionPricePoints").map((i) => [i.id, i]),
|
|
433
|
+
);
|
|
434
|
+
for (const price of data) {
|
|
435
|
+
if (price.relationships?.territory?.data?.id !== "USA") continue;
|
|
436
|
+
const ppId = price.relationships?.subscriptionPricePoint?.data?.id;
|
|
437
|
+
const pp = ppMap.get(ppId);
|
|
438
|
+
if (pp) return Number(pp.attributes.customerPrice);
|
|
439
|
+
}
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
const { data, included } = await client.getAllPages(
|
|
443
|
+
`/inAppPurchasePriceSchedules/${product.id}/manualPrices`,
|
|
444
|
+
{
|
|
445
|
+
include: "inAppPurchasePricePoint",
|
|
446
|
+
"fields[inAppPurchasePricePoints]": "customerPrice",
|
|
447
|
+
},
|
|
448
|
+
);
|
|
449
|
+
const ppMap = new Map(
|
|
450
|
+
included.filter((i) => i.type === "inAppPurchasePricePoints").map((i) => [i.id, i]),
|
|
451
|
+
);
|
|
452
|
+
for (const price of data) {
|
|
453
|
+
if (price.attributes?.startDate != null) continue; // skip scheduled future prices
|
|
454
|
+
const ppId = price.relationships?.inAppPurchasePricePoint?.data?.id || "";
|
|
455
|
+
if (decodeTerritory(ppId) === "USA") {
|
|
456
|
+
const pp = ppMap.get(ppId);
|
|
457
|
+
if (pp) return Number(pp.attributes.customerPrice);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Parse raw price-point rows into { id, customerPrice, territory3 }, skipping bad ones. */
|
|
464
|
+
function pppParsePoints(rows) {
|
|
465
|
+
const out = [];
|
|
466
|
+
for (const pp of rows) {
|
|
467
|
+
const price = pp.attributes?.customerPrice;
|
|
468
|
+
const territory3 = decodeTerritory(pp.id);
|
|
469
|
+
if (price == null || !territory3) continue;
|
|
470
|
+
const n = Number(price);
|
|
471
|
+
if (Number.isFinite(n)) out.push({ id: pp.id, customerPrice: n, territory3 });
|
|
472
|
+
}
|
|
473
|
+
return out;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** All local price points per territory (keyed by 3-letter code), each sorted asc. */
|
|
477
|
+
async function pppFetchTerritoryGrids(product, territories) {
|
|
478
|
+
const path = product.isSubscription
|
|
479
|
+
? `/subscriptions/${product.id}/pricePoints`
|
|
480
|
+
: `/v2/inAppPurchases/${product.id}/pricePoints`;
|
|
481
|
+
const unique = [...new Set(territories)];
|
|
482
|
+
const batches = [];
|
|
483
|
+
for (let i = 0; i < unique.length; i += PPP_TERRITORIES_PER_REQUEST)
|
|
484
|
+
batches.push(unique.slice(i, i + PPP_TERRITORIES_PER_REQUEST));
|
|
485
|
+
|
|
486
|
+
const grids = {};
|
|
487
|
+
await mapLimit(batches, 5, async (batch) => {
|
|
488
|
+
try {
|
|
489
|
+
const rows = await client.getAll(path, {
|
|
490
|
+
"filter[territory]": batch.join(","),
|
|
491
|
+
limit: 8000,
|
|
492
|
+
});
|
|
493
|
+
for (const pt of pppParsePoints(rows)) {
|
|
494
|
+
(grids[pt.territory3] ||= []).push(pt);
|
|
495
|
+
}
|
|
496
|
+
} catch (e) {
|
|
497
|
+
/* a failed batch just yields no grid for those territories */
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
for (const pts of Object.values(grids)) pts.sort((a, b) => a.customerPrice - b.customerPrice);
|
|
501
|
+
return grids;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Apple's equalized price points for ALL territories, from a USD base point. */
|
|
505
|
+
async function pppFetchEqualizations(product, usdPointId) {
|
|
506
|
+
const path = product.isSubscription
|
|
507
|
+
? `/subscriptionPricePoints/${usdPointId}/equalizations`
|
|
508
|
+
: `/inAppPurchasePricePoints/${usdPointId}/equalizations`;
|
|
509
|
+
const rows = await client.getAll(path, { limit: 200 });
|
|
510
|
+
const out = {};
|
|
511
|
+
for (const pt of pppParsePoints(rows)) out[pt.territory3] = pt;
|
|
512
|
+
return out;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Territory -> ISO currency, for display. */
|
|
516
|
+
async function pppFetchCurrencies() {
|
|
517
|
+
try {
|
|
518
|
+
const data = await client.getAll("/territories", { limit: 200 });
|
|
519
|
+
const out = {};
|
|
520
|
+
for (const t of data) out[t.id] = t.attributes?.currency || "";
|
|
521
|
+
return out;
|
|
522
|
+
} catch {
|
|
523
|
+
return {};
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Shared resolution used by both preview and apply. Fetches the product, US
|
|
529
|
+
* price, the USA base tier, equalizations and territory grids, then snaps each
|
|
530
|
+
* territory's coefficient-scaled target (in LOCAL currency) onto a real grid
|
|
531
|
+
* point. Returns everything needed to display or apply.
|
|
532
|
+
*/
|
|
533
|
+
async function pppResolve(a) {
|
|
534
|
+
const appId = a.appId;
|
|
535
|
+
const products = await pppFetchProducts(appId);
|
|
536
|
+
const product = products.find((p) => p.productId === a.productId || p.id === a.productId);
|
|
537
|
+
if (!product)
|
|
538
|
+
return {
|
|
539
|
+
error: `Product '${a.productId}' not found for app ${appId}.`,
|
|
540
|
+
availableProducts: products.map((p) => ({
|
|
541
|
+
productId: p.productId,
|
|
542
|
+
type: p.isSubscription ? "SUB" : "IAP",
|
|
543
|
+
})),
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
if (!product.isSubscription && (a.preserveCurrentPrice || a.startDate))
|
|
547
|
+
return {
|
|
548
|
+
error: `preserveCurrentPrice / startDate apply only to subscriptions; '${product.productId}' is an IAP.`,
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
const usPrice =
|
|
552
|
+
a.usPrice != null ? Number(a.usPrice) : await pppFetchUsPrice(product);
|
|
553
|
+
if (usPrice == null || !Number.isFinite(usPrice) || usPrice <= 0)
|
|
554
|
+
return { error: `Could not determine a US price for '${product.productId}'. Pass usPrice to override.` };
|
|
555
|
+
|
|
556
|
+
let overrides;
|
|
557
|
+
try {
|
|
558
|
+
overrides = validateCoefficients(a.coefficients);
|
|
559
|
+
} catch (e) {
|
|
560
|
+
return { error: e.message };
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const exclude = Array.isArray(a.exclude)
|
|
564
|
+
? a.exclude
|
|
565
|
+
: typeof a.exclude === "string"
|
|
566
|
+
? a.exclude.split(",")
|
|
567
|
+
: [];
|
|
568
|
+
const countries = loadCountries(exclude);
|
|
569
|
+
const targets = calculateTargetPrices(
|
|
570
|
+
[{ id: product.id, name: product.name, productId: product.productId, usPrice }],
|
|
571
|
+
countries,
|
|
572
|
+
overrides,
|
|
573
|
+
);
|
|
574
|
+
|
|
575
|
+
// USA base tier: the USA grid point nearest the US price.
|
|
576
|
+
const usdGrid = (await pppFetchTerritoryGrids(product, ["USA"]))["USA"] || [];
|
|
577
|
+
if (!usdGrid.length)
|
|
578
|
+
return { error: "No USD price points available for this product." };
|
|
579
|
+
const usTier = findPricePoint(usdGrid, usPrice, usPrice);
|
|
580
|
+
if (!usTier) return { error: "No matching Apple price tier for the US price." };
|
|
581
|
+
|
|
582
|
+
const baselines = await pppFetchEqualizations(product, usTier.id);
|
|
583
|
+
if (!Object.keys(baselines).length)
|
|
584
|
+
return { error: "Could not load Apple's territory prices (equalizations) for the US price." };
|
|
585
|
+
|
|
586
|
+
const codes = targets.map((t) => t.countryCode);
|
|
587
|
+
const grids = await pppFetchTerritoryGrids(product, codes);
|
|
588
|
+
|
|
589
|
+
const resolved = {}; // territory3 -> { pointId, customerPrice }
|
|
590
|
+
const rows = [];
|
|
591
|
+
for (const t of targets) {
|
|
592
|
+
const base = baselines[t.countryCode];
|
|
593
|
+
const grid = grids[t.countryCode];
|
|
594
|
+
if (!base || !grid) {
|
|
595
|
+
rows.push({ ...t, skipped: "no local grid/baseline" });
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
const localTarget = base.customerPrice * t.coefficient;
|
|
599
|
+
const point = findPricePoint(grid, localTarget, base.customerPrice);
|
|
600
|
+
if (!point) {
|
|
601
|
+
rows.push({ ...t, skipped: "no grid point" });
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
resolved[t.countryCode] = point;
|
|
605
|
+
rows.push({
|
|
606
|
+
territory: t.countryCode,
|
|
607
|
+
country: t.countryName,
|
|
608
|
+
category: t.category,
|
|
609
|
+
coefficient: Math.round(t.coefficient * 1000) / 1000,
|
|
610
|
+
appleDefaultLocal: base.customerPrice,
|
|
611
|
+
chosenLocal: point.customerPrice,
|
|
612
|
+
pricePointId: point.id,
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
// Include USA itself (its own tier) so apply sets/keeps the base price too.
|
|
616
|
+
resolved.USA = usTier;
|
|
617
|
+
|
|
618
|
+
return { product, usPrice, usTier, resolved, rows, baselines, overrides };
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** Set all territory prices for an IAP in a single atomic price-schedule POST. */
|
|
622
|
+
async function pppApplyIapPrices(iapId, resolved) {
|
|
623
|
+
const refs = [];
|
|
624
|
+
const included = [];
|
|
625
|
+
let i = 0;
|
|
626
|
+
for (const point of Object.values(resolved)) {
|
|
627
|
+
const tempId = `\${price${i++}}`;
|
|
628
|
+
refs.push({ type: "inAppPurchasePrices", id: tempId });
|
|
629
|
+
included.push({
|
|
630
|
+
type: "inAppPurchasePrices",
|
|
631
|
+
id: tempId,
|
|
632
|
+
attributes: { startDate: null },
|
|
633
|
+
relationships: {
|
|
634
|
+
inAppPurchaseV2: { data: { type: "inAppPurchases", id: iapId } },
|
|
635
|
+
inAppPurchasePricePoint: {
|
|
636
|
+
data: { type: "inAppPurchasePricePoints", id: point.id },
|
|
637
|
+
},
|
|
638
|
+
},
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
return client.post("/inAppPurchasePriceSchedules", {
|
|
642
|
+
data: {
|
|
643
|
+
type: "inAppPurchasePriceSchedules",
|
|
644
|
+
relationships: {
|
|
645
|
+
inAppPurchase: { data: { type: "inAppPurchases", id: iapId } },
|
|
646
|
+
baseTerritory: { data: { type: "territories", id: "USA" } },
|
|
647
|
+
manualPrices: { data: refs },
|
|
648
|
+
},
|
|
649
|
+
},
|
|
650
|
+
included,
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** Delete pending (future-dated) subscription prices to avoid 409 conflicts. */
|
|
655
|
+
async function pppDeletePendingSubPrices(subId) {
|
|
656
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
657
|
+
const data = await client.getAll(`/subscriptions/${subId}/prices`, { limit: 200 });
|
|
658
|
+
let deleted = 0;
|
|
659
|
+
for (const price of data) {
|
|
660
|
+
const start = price.attributes?.startDate;
|
|
661
|
+
if (start && start >= today) {
|
|
662
|
+
try {
|
|
663
|
+
await client.delete(`/subscriptionPrices/${price.id}`);
|
|
664
|
+
deleted++;
|
|
665
|
+
} catch {
|
|
666
|
+
/* best-effort */
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
return deleted;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** Set one subscription territory price. */
|
|
674
|
+
function pppSetSubPrice(subId, territory3, point, preserved, startIso) {
|
|
675
|
+
return client.post("/subscriptionPrices", {
|
|
676
|
+
data: {
|
|
677
|
+
type: "subscriptionPrices",
|
|
678
|
+
attributes: { preserveCurrentPrice: !!preserved, startDate: startIso },
|
|
679
|
+
relationships: {
|
|
680
|
+
subscription: { data: { type: "subscriptions", id: subId } },
|
|
681
|
+
subscriptionPricePoint: {
|
|
682
|
+
data: { type: "subscriptionPricePoints", id: point.id },
|
|
683
|
+
},
|
|
684
|
+
territory: { data: { type: "territories", id: territory3 } },
|
|
685
|
+
},
|
|
686
|
+
},
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** Apply resolved prices. IAP = one request; subscription = per-territory. */
|
|
691
|
+
async function pppApply(product, resolved, { preserved, startDate } = {}) {
|
|
692
|
+
if (!product.isSubscription) {
|
|
693
|
+
await pppApplyIapPrices(product.id, resolved);
|
|
694
|
+
return { applied: Object.keys(resolved).length, failed: 0, territories: Object.keys(resolved).length };
|
|
695
|
+
}
|
|
696
|
+
// Subscriptions: default start 2 days out; clear pending first.
|
|
697
|
+
const startIso =
|
|
698
|
+
startDate || new Date(Date.now() + 2 * 86400000).toISOString().slice(0, 10);
|
|
699
|
+
const deletedPending = await pppDeletePendingSubPrices(product.id);
|
|
700
|
+
const entries = Object.entries(resolved);
|
|
701
|
+
let applied = 0;
|
|
702
|
+
const failures = [];
|
|
703
|
+
await mapLimit(entries, 10, async ([territory, point]) => {
|
|
704
|
+
try {
|
|
705
|
+
await pppSetSubPrice(product.id, territory, point, preserved, startIso);
|
|
706
|
+
applied++;
|
|
707
|
+
} catch (e) {
|
|
708
|
+
failures.push({ territory, error: e.message });
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
return {
|
|
712
|
+
applied,
|
|
713
|
+
failed: failures.length,
|
|
714
|
+
territories: entries.length,
|
|
715
|
+
startDate: startIso,
|
|
716
|
+
preserveCurrentPrice: !!preserved,
|
|
717
|
+
deletedPending,
|
|
718
|
+
failures: failures.slice(0, 10),
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// ---- Build / submission helpers ----
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Resolve a build to { id, attributes }. `ref` may be a build number (digits,
|
|
726
|
+
* resolved via filter[version] within the app) or an ASC build id.
|
|
727
|
+
*/
|
|
728
|
+
async function resolveBuild(appId, ref) {
|
|
729
|
+
if (/^\d+$/.test(String(ref))) {
|
|
730
|
+
if (!appId) throw new Error("appId is required to resolve a build by its number.");
|
|
731
|
+
const page = await client.get(`/builds`, {
|
|
732
|
+
"filter[app]": appId,
|
|
733
|
+
"filter[version]": String(ref),
|
|
734
|
+
limit: 1,
|
|
735
|
+
});
|
|
736
|
+
const b = page.data?.[0];
|
|
737
|
+
if (!b) throw new Error(`No build numbered ${ref} found for app ${appId}.`);
|
|
738
|
+
return { id: b.id, attributes: b.attributes || {} };
|
|
739
|
+
}
|
|
740
|
+
const res = await client.get(`/builds/${ref}`);
|
|
741
|
+
return { id: res.data.id, attributes: res.data.attributes || {} };
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Inspect a version for the usual silent submit blockers and return a list of
|
|
746
|
+
* issues. Covers: no build attached, build not VALID, unset export compliance,
|
|
747
|
+
* unset IDFA declaration, and screenshots/previews still processing.
|
|
748
|
+
*/
|
|
749
|
+
async function diagnoseSubmitBlockers(appId, versionId) {
|
|
750
|
+
const issues = [];
|
|
751
|
+
let versionAttrs = {};
|
|
752
|
+
try {
|
|
753
|
+
const v = await client.get(`/appStoreVersions/${versionId}`, {
|
|
754
|
+
include: "build",
|
|
755
|
+
});
|
|
756
|
+
versionAttrs = v.data?.attributes || {};
|
|
757
|
+
const buildRel = v.data?.relationships?.build?.data;
|
|
758
|
+
if (!buildRel) {
|
|
759
|
+
issues.push({
|
|
760
|
+
area: "build",
|
|
761
|
+
issue: "No build is attached to this version.",
|
|
762
|
+
fix: "attach_build_to_version(versionId, buildId|buildNumber).",
|
|
763
|
+
});
|
|
764
|
+
} else {
|
|
765
|
+
try {
|
|
766
|
+
const b = await client.get(`/builds/${buildRel.id}`);
|
|
767
|
+
const ba = b.data?.attributes || {};
|
|
768
|
+
if (ba.processingState && ba.processingState !== "VALID")
|
|
769
|
+
issues.push({
|
|
770
|
+
area: "build",
|
|
771
|
+
issue: `Attached build is ${ba.processingState}, not VALID.`,
|
|
772
|
+
fix: "Wait for processing (wait_for_build_processing) before submitting.",
|
|
773
|
+
});
|
|
774
|
+
if (ba.usesNonExemptEncryption == null)
|
|
775
|
+
issues.push({
|
|
776
|
+
area: "export-compliance",
|
|
777
|
+
issue: "Build's usesNonExemptEncryption is unset (per-build; does not carry over).",
|
|
778
|
+
fix: "update_build(buildId, usesNonExemptEncryption:false|true). This is a common cause of the opaque 409.",
|
|
779
|
+
});
|
|
780
|
+
} catch {
|
|
781
|
+
/* build fetch best-effort */
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
if (versionAttrs.usesIdfa == null)
|
|
785
|
+
issues.push({
|
|
786
|
+
area: "idfa",
|
|
787
|
+
issue: "Version's usesIdfa is unset.",
|
|
788
|
+
fix: "update_app_store_version(versionId, usesIdfa:false|true).",
|
|
789
|
+
});
|
|
790
|
+
} catch (e) {
|
|
791
|
+
issues.push({ area: "version", issue: `Could not read version: ${e.message}` });
|
|
792
|
+
}
|
|
793
|
+
// Screenshots / previews still processing anywhere block submission.
|
|
794
|
+
try {
|
|
795
|
+
const stuck = await findIncompleteAssets(versionId);
|
|
796
|
+
if (stuck.screenshots.length || stuck.previews.length)
|
|
797
|
+
issues.push({
|
|
798
|
+
area: "assets",
|
|
799
|
+
issue: `${stuck.screenshots.length} screenshot(s) and ${stuck.previews.length} preview(s) not COMPLETE (ASC shows "uploads in progress").`,
|
|
800
|
+
fix: "Wait for processing; see find_incomplete_screenshots(versionId) for the exact assets.",
|
|
801
|
+
locales: [...new Set([...stuck.screenshots, ...stuck.previews].map((s) => s.locale))].slice(0, 10),
|
|
802
|
+
});
|
|
803
|
+
} catch {
|
|
804
|
+
/* best-effort */
|
|
805
|
+
}
|
|
806
|
+
return issues;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/** Find screenshots/previews whose assetDeliveryState.state != COMPLETE. */
|
|
810
|
+
async function findIncompleteAssets(versionId) {
|
|
811
|
+
const screenshots = [];
|
|
812
|
+
const previews = [];
|
|
813
|
+
const locs = await client.getAll(
|
|
814
|
+
`/appStoreVersions/${versionId}/appStoreVersionLocalizations`,
|
|
815
|
+
);
|
|
816
|
+
await mapLimit(locs, 6, async (loc) => {
|
|
817
|
+
const locale = loc.attributes?.locale;
|
|
818
|
+
const sets = await client.getAll(`/appStoreVersionLocalizations/${loc.id}/appScreenshotSets`);
|
|
819
|
+
await mapLimit(sets, 4, async (set) => {
|
|
820
|
+
const shots = await client.getAll(`/appScreenshotSets/${set.id}/appScreenshots`);
|
|
821
|
+
for (const sh of shots) {
|
|
822
|
+
const state = sh.attributes?.assetDeliveryState?.state;
|
|
823
|
+
if (state && state !== "COMPLETE")
|
|
824
|
+
screenshots.push({
|
|
825
|
+
id: sh.id,
|
|
826
|
+
locale,
|
|
827
|
+
displayType: set.attributes?.screenshotDisplayType,
|
|
828
|
+
fileName: sh.attributes?.fileName,
|
|
829
|
+
state,
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
});
|
|
833
|
+
const pvSets = await client.getAll(`/appStoreVersionLocalizations/${loc.id}/appPreviewSets`);
|
|
834
|
+
await mapLimit(pvSets, 4, async (set) => {
|
|
835
|
+
const pvs = await client.getAll(`/appPreviewSets/${set.id}/appPreviews`);
|
|
836
|
+
for (const pv of pvs) {
|
|
837
|
+
const state = pv.attributes?.assetDeliveryState?.state;
|
|
838
|
+
if (state && state !== "COMPLETE")
|
|
839
|
+
previews.push({
|
|
840
|
+
id: pv.id,
|
|
841
|
+
locale,
|
|
842
|
+
previewType: set.attributes?.previewType,
|
|
843
|
+
fileName: pv.attributes?.fileName,
|
|
844
|
+
state,
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
});
|
|
848
|
+
});
|
|
849
|
+
return { screenshots, previews };
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/** Reserve → upload → commit one screenshot file into a set. Returns a compact row. */
|
|
853
|
+
async function uploadScreenshotFile(setId, filePath, fileName) {
|
|
854
|
+
const buf = readFileSync(filePath);
|
|
855
|
+
const name = fileName || basename(filePath);
|
|
856
|
+
const reservation = await client.post(`/appScreenshots`, {
|
|
857
|
+
data: {
|
|
858
|
+
type: "appScreenshots",
|
|
859
|
+
attributes: { fileName: name, fileSize: buf.length },
|
|
860
|
+
relationships: {
|
|
861
|
+
appScreenshotSet: { data: { type: "appScreenshotSets", id: setId } },
|
|
862
|
+
},
|
|
863
|
+
},
|
|
864
|
+
});
|
|
865
|
+
const id = reservation.data.id;
|
|
866
|
+
await client.uploadAsset(reservation.data.attributes.uploadOperations, buf);
|
|
867
|
+
const committed = await client.patch(`/appScreenshots/${id}`, {
|
|
868
|
+
data: {
|
|
869
|
+
type: "appScreenshots",
|
|
870
|
+
id,
|
|
871
|
+
attributes: { uploaded: true, sourceFileChecksum: AppStoreConnectClient.md5(buf) },
|
|
872
|
+
},
|
|
873
|
+
});
|
|
874
|
+
const at = committed?.data?.attributes || {};
|
|
875
|
+
return { id, fileName: at.fileName || name, state: at.assetDeliveryState?.state || "UPLOAD_COMPLETE" };
|
|
876
|
+
}
|
|
877
|
+
|
|
123
878
|
/** Throw a friendly install-guidance error if Xcode CLI tools aren't available. */
|
|
124
879
|
async function ensureXcode() {
|
|
125
880
|
if (process.platform !== "darwin")
|
|
@@ -491,15 +1246,34 @@ const tools = [
|
|
|
491
1246
|
const attributes = { locale: a.locale };
|
|
492
1247
|
for (const k of ["name", "subtitle", "privacyPolicyUrl", "privacyPolicyText"])
|
|
493
1248
|
if (a[k] !== undefined) attributes[k] = a[k];
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
1249
|
+
// Upsert: Apple auto-creates the name/subtitle entry for a locale as soon
|
|
1250
|
+
// as that locale's version localization exists, so a plain POST can 409
|
|
1251
|
+
// with "already exists". On conflict, find the existing row and PATCH it.
|
|
1252
|
+
try {
|
|
1253
|
+
return await client.post(`/appInfoLocalizations`, {
|
|
1254
|
+
data: {
|
|
1255
|
+
type: "appInfoLocalizations",
|
|
1256
|
+
attributes,
|
|
1257
|
+
relationships: {
|
|
1258
|
+
appInfo: { data: { type: "appInfos", id: a.appInfoId } },
|
|
1259
|
+
},
|
|
500
1260
|
},
|
|
501
|
-
}
|
|
502
|
-
})
|
|
1261
|
+
});
|
|
1262
|
+
} catch (e) {
|
|
1263
|
+
const conflict = e.status === 409 || /already exist/i.test(e.message || "");
|
|
1264
|
+
if (!conflict) throw e;
|
|
1265
|
+
const existing = await client.getAll(
|
|
1266
|
+
`/appInfos/${a.appInfoId}/appInfoLocalizations`,
|
|
1267
|
+
);
|
|
1268
|
+
const row = existing.find((x) => x.attributes?.locale === a.locale);
|
|
1269
|
+
if (!row) throw e;
|
|
1270
|
+
const patchAttrs = { ...attributes };
|
|
1271
|
+
delete patchAttrs.locale; // locale is immutable on update
|
|
1272
|
+
const res = await client.patch(`/appInfoLocalizations/${row.id}`, {
|
|
1273
|
+
data: { type: "appInfoLocalizations", id: row.id, attributes: patchAttrs },
|
|
1274
|
+
});
|
|
1275
|
+
return { ...res, _upserted: "updated existing localization for this locale" };
|
|
1276
|
+
}
|
|
503
1277
|
},
|
|
504
1278
|
},
|
|
505
1279
|
|
|
@@ -570,17 +1344,32 @@ const tools = [
|
|
|
570
1344
|
{
|
|
571
1345
|
name: "list_app_store_version_localizations",
|
|
572
1346
|
description:
|
|
573
|
-
"List the per-locale localizations of an App Store version. Each holds: description, keywords, promotionalText, whatsNew, marketingUrl, supportUrl. Use the localization id to read/update copy and to find screenshot sets.",
|
|
1347
|
+
"List the per-locale localizations of an App Store version. Each holds: description, keywords, promotionalText, whatsNew, marketingUrl, supportUrl. Use the localization id to read/update copy and to find screenshot sets. Pass omitLongFields:true to replace description/promotionalText/whatsNew with their character counts (descriptionLength etc.) — much lighter when scanning many locales.",
|
|
574
1348
|
inputSchema: {
|
|
575
1349
|
type: "object",
|
|
576
|
-
properties: {
|
|
1350
|
+
properties: {
|
|
1351
|
+
versionId: { type: "string" },
|
|
1352
|
+
omitLongFields: {
|
|
1353
|
+
type: "boolean",
|
|
1354
|
+
description: "Return lengths instead of full description/promotionalText/whatsNew text",
|
|
1355
|
+
},
|
|
1356
|
+
},
|
|
577
1357
|
required: ["versionId"],
|
|
578
1358
|
},
|
|
579
1359
|
run: async (a) => {
|
|
580
1360
|
const data = await client.getAll(
|
|
581
1361
|
`/appStoreVersions/${a.versionId}/appStoreVersionLocalizations`,
|
|
582
1362
|
);
|
|
583
|
-
return data.map((x) =>
|
|
1363
|
+
return data.map((x) => {
|
|
1364
|
+
const attrs = { ...x.attributes };
|
|
1365
|
+
if (a.omitLongFields) {
|
|
1366
|
+
for (const f of ["description", "promotionalText", "whatsNew"]) {
|
|
1367
|
+
attrs[`${f}Length`] = (attrs[f] || "").length;
|
|
1368
|
+
delete attrs[f];
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
return { id: x.id, ...attrs };
|
|
1372
|
+
});
|
|
584
1373
|
},
|
|
585
1374
|
},
|
|
586
1375
|
{
|
|
@@ -805,7 +1594,16 @@ const tools = [
|
|
|
805
1594
|
},
|
|
806
1595
|
},
|
|
807
1596
|
});
|
|
808
|
-
|
|
1597
|
+
// Return a compact result — the raw response carries large signed upload
|
|
1598
|
+
// URLs that waste context and are useless after commit.
|
|
1599
|
+
const at = committed?.data?.attributes || {};
|
|
1600
|
+
return {
|
|
1601
|
+
id,
|
|
1602
|
+
fileName: at.fileName || fileName,
|
|
1603
|
+
fileSize: at.fileSize ?? buf.length,
|
|
1604
|
+
state: at.assetDeliveryState?.state || "UPLOAD_COMPLETE",
|
|
1605
|
+
uploaded: true,
|
|
1606
|
+
};
|
|
809
1607
|
},
|
|
810
1608
|
},
|
|
811
1609
|
{
|
|
@@ -1287,23 +2085,39 @@ const tools = [
|
|
|
1287
2085
|
{
|
|
1288
2086
|
name: "list_analytics_reports",
|
|
1289
2087
|
description:
|
|
1290
|
-
"List the reports available under an analytics report request (from request_analytics_report). category filter: APP_USAGE, APP_STORE_ENGAGEMENT, COMMERCE, FRAMEWORK_USAGE, PERFORMANCE.",
|
|
2088
|
+
"List the reports available under an analytics report request. Pass requestId (from request_analytics_report) OR just appId — with appId, the newest existing report request for the app is used (if none exists, you're told to call request_analytics_report first). category filter: APP_USAGE, APP_STORE_ENGAGEMENT, COMMERCE, FRAMEWORK_USAGE, PERFORMANCE.",
|
|
1291
2089
|
inputSchema: {
|
|
1292
2090
|
type: "object",
|
|
1293
2091
|
properties: {
|
|
1294
|
-
requestId: { type: "string" },
|
|
2092
|
+
requestId: { type: "string", description: "An analyticsReportRequest id" },
|
|
2093
|
+
appId: { type: "string", description: "Alternative to requestId: use the app's newest report request" },
|
|
1295
2094
|
category: { type: "string" },
|
|
1296
2095
|
},
|
|
1297
|
-
required: ["requestId"],
|
|
1298
2096
|
},
|
|
1299
2097
|
run: async (a) => {
|
|
2098
|
+
let requestId = a.requestId;
|
|
2099
|
+
if (!requestId) {
|
|
2100
|
+
if (!a.appId)
|
|
2101
|
+
return { error: "Pass requestId or appId." };
|
|
2102
|
+
const reqs = await client.getAll(`/apps/${a.appId}/analyticsReportRequests`, {
|
|
2103
|
+
limit: 50,
|
|
2104
|
+
});
|
|
2105
|
+
if (!reqs.length)
|
|
2106
|
+
return {
|
|
2107
|
+
error:
|
|
2108
|
+
"No analytics report request exists for this app yet. Call request_analytics_report first (generation is async and can take minutes–hours).",
|
|
2109
|
+
};
|
|
2110
|
+
// Prefer an ONGOING request, else the first returned.
|
|
2111
|
+
requestId =
|
|
2112
|
+
(reqs.find((r) => r.attributes?.accessType === "ONGOING") || reqs[0]).id;
|
|
2113
|
+
}
|
|
1300
2114
|
const q = {};
|
|
1301
2115
|
if (a.category) q["filter[category]"] = a.category;
|
|
1302
2116
|
const data = await client.getAll(
|
|
1303
|
-
`/analyticsReportRequests/${
|
|
2117
|
+
`/analyticsReportRequests/${requestId}/reports`,
|
|
1304
2118
|
q,
|
|
1305
2119
|
);
|
|
1306
|
-
return data.map((x) => ({ id: x.id, ...x.attributes }));
|
|
2120
|
+
return { requestId, reports: data.map((x) => ({ id: x.id, ...x.attributes })) };
|
|
1307
2121
|
},
|
|
1308
2122
|
},
|
|
1309
2123
|
{
|
|
@@ -1441,23 +2255,31 @@ const tools = [
|
|
|
1441
2255
|
{
|
|
1442
2256
|
name: "list_builds",
|
|
1443
2257
|
description:
|
|
1444
|
-
"List TestFlight builds for an app (newest first): version, upload/expiration dates, processing state, min OS.",
|
|
2258
|
+
"List TestFlight builds for an app (newest first): version, upload/expiration dates, processing state, min OS. Optionally filter by build number (version) or processing state.",
|
|
1445
2259
|
inputSchema: {
|
|
1446
2260
|
type: "object",
|
|
1447
2261
|
properties: {
|
|
1448
2262
|
appId: { type: "string" },
|
|
1449
|
-
limit: { type: "number", description: "Max builds (default 25)" },
|
|
2263
|
+
limit: { type: "number", description: "Max builds to return (default 25)" },
|
|
2264
|
+
version: { type: "string", description: "Filter to a specific build number, e.g. '42'" },
|
|
2265
|
+
processingState: { type: "string", description: "PROCESSING, VALID, INVALID, FAILED" },
|
|
1450
2266
|
},
|
|
1451
2267
|
required: ["appId"],
|
|
1452
2268
|
},
|
|
1453
2269
|
run: async (a) => {
|
|
1454
|
-
//
|
|
1455
|
-
|
|
2270
|
+
// Use a single page sized to the requested limit — getAll() would follow
|
|
2271
|
+
// links.next and return far more than `limit` asks for (per-page size).
|
|
2272
|
+
const limit = a.limit ?? 25;
|
|
2273
|
+
const query = {
|
|
1456
2274
|
"filter[app]": a.appId,
|
|
1457
2275
|
sort: "-version",
|
|
1458
|
-
limit:
|
|
1459
|
-
}
|
|
1460
|
-
|
|
2276
|
+
limit: Math.min(Math.max(limit, 1), 200),
|
|
2277
|
+
};
|
|
2278
|
+
if (a.version) query["filter[version]"] = a.version;
|
|
2279
|
+
if (a.processingState) query["filter[processingState]"] = a.processingState;
|
|
2280
|
+
const page = await client.get(`/builds`, query);
|
|
2281
|
+
const data = Array.isArray(page.data) ? page.data : [];
|
|
2282
|
+
return data.slice(0, limit).map((x) => ({ id: x.id, ...x.attributes }));
|
|
1461
2283
|
},
|
|
1462
2284
|
},
|
|
1463
2285
|
{
|
|
@@ -1962,35 +2784,63 @@ const tools = [
|
|
|
1962
2784
|
required: ["appId", "versionId"],
|
|
1963
2785
|
},
|
|
1964
2786
|
run: async (a) => {
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
2787
|
+
// Pre-flight: surface silent blockers before Apple's opaque 409.
|
|
2788
|
+
const preIssues = await diagnoseSubmitBlockers(a.appId, a.versionId).catch(() => []);
|
|
2789
|
+
if (preIssues.length)
|
|
2790
|
+
return {
|
|
2791
|
+
submitted: false,
|
|
2792
|
+
blocked: true,
|
|
2793
|
+
reason:
|
|
2794
|
+
"Not submitting — the version has blockers Apple would reject with an opaque 409. Fix these and retry:",
|
|
2795
|
+
issues: preIssues,
|
|
2796
|
+
};
|
|
2797
|
+
let subId;
|
|
2798
|
+
try {
|
|
2799
|
+
const sub = await client.post("/reviewSubmissions", {
|
|
2800
|
+
data: {
|
|
2801
|
+
type: "reviewSubmissions",
|
|
2802
|
+
attributes: { platform: a.platform || "IOS" },
|
|
2803
|
+
relationships: { app: { data: { type: "apps", id: a.appId } } },
|
|
2804
|
+
},
|
|
2805
|
+
});
|
|
2806
|
+
subId = sub.data.id;
|
|
2807
|
+
await client.post("/reviewSubmissionItems", {
|
|
2808
|
+
data: {
|
|
2809
|
+
type: "reviewSubmissionItems",
|
|
2810
|
+
relationships: {
|
|
2811
|
+
reviewSubmission: { data: { type: "reviewSubmissions", id: subId } },
|
|
2812
|
+
appStoreVersion: { data: { type: "appStoreVersions", id: a.versionId } },
|
|
1982
2813
|
},
|
|
1983
2814
|
},
|
|
1984
|
-
}
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
}
|
|
1992
|
-
|
|
1993
|
-
|
|
2815
|
+
});
|
|
2816
|
+
const submitted = await client.patch(`/reviewSubmissions/${subId}`, {
|
|
2817
|
+
data: {
|
|
2818
|
+
type: "reviewSubmissions",
|
|
2819
|
+
id: subId,
|
|
2820
|
+
attributes: { submitted: true },
|
|
2821
|
+
},
|
|
2822
|
+
});
|
|
2823
|
+
return { submitted: true, reviewSubmissionId: subId, result: submitted };
|
|
2824
|
+
} catch (e) {
|
|
2825
|
+
// On failure, diagnose and surface the likely real cause; clean up the
|
|
2826
|
+
// half-created (empty) submission so it doesn't orphan.
|
|
2827
|
+
const issues = await diagnoseSubmitBlockers(a.appId, a.versionId).catch(() => []);
|
|
2828
|
+
if (subId) {
|
|
2829
|
+
try {
|
|
2830
|
+
await client.patch(`/reviewSubmissions/${subId}`, {
|
|
2831
|
+
data: { type: "reviewSubmissions", id: subId, attributes: { canceled: true } },
|
|
2832
|
+
});
|
|
2833
|
+
} catch {
|
|
2834
|
+
/* best-effort cleanup */
|
|
2835
|
+
}
|
|
2836
|
+
}
|
|
2837
|
+
return {
|
|
2838
|
+
submitted: false,
|
|
2839
|
+
error: e.message,
|
|
2840
|
+
likelyCauses: issues.length ? issues : "No specific blocker detected — see the raw error above.",
|
|
2841
|
+
reviewSubmissionId: subId,
|
|
2842
|
+
};
|
|
2843
|
+
}
|
|
1994
2844
|
},
|
|
1995
2845
|
},
|
|
1996
2846
|
{
|
|
@@ -2628,20 +3478,52 @@ const tools = [
|
|
|
2628
3478
|
const ed = versions.find((v) => EDITABLE_VERSION_STATES.has(v.attributes.appStoreState)) || versions[0];
|
|
2629
3479
|
add("Version", ed ? "info" : "warn", ed ? `v${ed.attributes.versionString} — ${ed.attributes.appStoreState}` : "no version");
|
|
2630
3480
|
if (ed) {
|
|
3481
|
+
// Is a build attached? (common silent submit blocker)
|
|
3482
|
+
try {
|
|
3483
|
+
const vFull = await client.get(`/appStoreVersions/${ed.id}`, { include: "build" });
|
|
3484
|
+
const buildRel = vFull.data?.relationships?.build?.data;
|
|
3485
|
+
if (!buildRel) add("Attached build", "fail", "no build attached to this version");
|
|
3486
|
+
else {
|
|
3487
|
+
const b = await client.get(`/builds/${buildRel.id}`);
|
|
3488
|
+
const ba = b.data?.attributes || {};
|
|
3489
|
+
add("Attached build", ba.processingState === "VALID" ? "pass" : "warn",
|
|
3490
|
+
`v${ba.version} — ${ba.processingState}` +
|
|
3491
|
+
(ba.usesNonExemptEncryption == null ? " · export compliance UNSET" : ""));
|
|
3492
|
+
}
|
|
3493
|
+
} catch (e) { add("Attached build", "warn", e.message.slice(0, 60)); }
|
|
3494
|
+
|
|
2631
3495
|
const locs = await client.getAll(`/appStoreVersions/${ed.id}/appStoreVersionLocalizations`);
|
|
2632
3496
|
const loc = locs.find((l) => l.attributes.locale === primaryLocale) || locs[0];
|
|
2633
3497
|
const at = (loc && loc.attributes) || {};
|
|
2634
|
-
add("Description", at.description ? "pass" : "fail", at.description ?
|
|
3498
|
+
add("Description", at.description ? "pass" : "fail", at.description ? `present (${primaryLocale})` : "missing");
|
|
2635
3499
|
const kw = (at.keywords || "").trim();
|
|
2636
3500
|
add("Keywords", kw ? (kw.length >= 70 ? "pass" : "warn") : "fail", kw ? `${kw.length}/100 chars` : "empty");
|
|
2637
3501
|
add("What's New", at.whatsNew ? "pass" : "warn", at.whatsNew ? "present" : "missing");
|
|
2638
3502
|
add("Support URL", at.supportUrl ? "pass" : "warn", at.supportUrl ? "set" : "missing");
|
|
2639
|
-
|
|
3503
|
+
|
|
3504
|
+
// Every locale: description + at least one screenshot.
|
|
3505
|
+
const noDesc = [];
|
|
3506
|
+
const noShots = [];
|
|
3507
|
+
await mapLimit(locs, 6, async (l) => {
|
|
3508
|
+
if (!(l.attributes?.description || "").trim()) noDesc.push(l.attributes?.locale);
|
|
2640
3509
|
let shots = 0;
|
|
2641
|
-
const sets = await client.getAll(`/appStoreVersionLocalizations/${
|
|
2642
|
-
for (const s of sets)
|
|
2643
|
-
|
|
2644
|
-
|
|
3510
|
+
const sets = await client.getAll(`/appStoreVersionLocalizations/${l.id}/appScreenshotSets`);
|
|
3511
|
+
for (const s of sets)
|
|
3512
|
+
shots += (await client.getAll(`/appScreenshotSets/${s.id}/appScreenshots`)).length;
|
|
3513
|
+
if (shots === 0) noShots.push(l.attributes?.locale);
|
|
3514
|
+
});
|
|
3515
|
+
add("Description (all locales)", noDesc.length ? "fail" : "pass",
|
|
3516
|
+
noDesc.length ? `missing in: ${noDesc.join(", ")}` : `${locs.length} locale(s) OK`);
|
|
3517
|
+
add("Screenshots (all locales)", noShots.length ? "fail" : "pass",
|
|
3518
|
+
noShots.length ? `none in: ${noShots.join(", ")}` : `${locs.length} locale(s) have screenshots`);
|
|
3519
|
+
|
|
3520
|
+
// Any asset still processing blocks submission.
|
|
3521
|
+
try {
|
|
3522
|
+
const stuck = await findIncompleteAssets(ed.id);
|
|
3523
|
+
const n = stuck.screenshots.length + stuck.previews.length;
|
|
3524
|
+
add("Asset processing", n ? "fail" : "pass",
|
|
3525
|
+
n ? `${n} asset(s) not COMPLETE (e.g. ${stuck.screenshots.concat(stuck.previews).slice(0, 3).map((x) => `${x.locale} ${x.fileName || ""}`).join("; ")})` : "all COMPLETE");
|
|
3526
|
+
} catch { /* best-effort */ }
|
|
2645
3527
|
}
|
|
2646
3528
|
try {
|
|
2647
3529
|
const infos = await client.getAll(`/apps/${a.appId}/appInfos`);
|
|
@@ -2940,6 +3822,1187 @@ ${a.teamId ? `<key>teamID</key><string>${a.teamId}</string>\n` : ""}<key>uploadS
|
|
|
2940
3822
|
},
|
|
2941
3823
|
},
|
|
2942
3824
|
|
|
3825
|
+
// ---- PPP (purchasing-power-parity) regional pricing ----
|
|
3826
|
+
{
|
|
3827
|
+
name: "list_purchasable_products",
|
|
3828
|
+
description:
|
|
3829
|
+
"List every purchasable product for an app — non-subscription in-app purchases (consumables/non-consumables) AND auto-renewable subscriptions — each with its current US price. Use this to find the productId to feed preview_ppp_prices / apply_ppp_prices.",
|
|
3830
|
+
inputSchema: {
|
|
3831
|
+
type: "object",
|
|
3832
|
+
properties: {
|
|
3833
|
+
appId: { type: "string", description: "App Store app id (9-digit)" },
|
|
3834
|
+
},
|
|
3835
|
+
required: ["appId"],
|
|
3836
|
+
},
|
|
3837
|
+
run: async (a) => {
|
|
3838
|
+
const products = await pppFetchProducts(a.appId);
|
|
3839
|
+
const withPrices = await mapLimit(products, 10, async (p) => ({
|
|
3840
|
+
productId: p.productId,
|
|
3841
|
+
name: p.name,
|
|
3842
|
+
type: p.isSubscription ? "SUB" : "IAP",
|
|
3843
|
+
usPrice: await pppFetchUsPrice(p).catch(() => null),
|
|
3844
|
+
}));
|
|
3845
|
+
return { appId: a.appId, count: withPrices.length, products: withPrices };
|
|
3846
|
+
},
|
|
3847
|
+
},
|
|
3848
|
+
{
|
|
3849
|
+
name: "preview_ppp_prices",
|
|
3850
|
+
description:
|
|
3851
|
+
"Dry run (READ-ONLY) for purchasing-power-parity regional pricing of ONE in-app purchase or subscription. Computes a per-territory price table from the US base price scaled by each country's PPP coefficient, snapped to Apple's real local price grid (scaling happens in local currency via equalizations, not dollars). ALWAYS run this and show the user the table before apply_ppp_prices — price changes affect real customers in ~174 territories and cannot be undone. " +
|
|
3852
|
+
"Coefficients: the embedded per-country defaults are used unless you pass `coefficients` to override a tier. Tiers: premium, high_income, upper_middle, lower_middle, emerging (USA is always the 1.00 base). Reason about the app's type/elasticity (games = high elasticity → discount more in poorer markets; AI/productivity = low elasticity → discount less) and pass overrides like {\"emerging\":0.55,\"lower_middle\":0.70}.",
|
|
3853
|
+
inputSchema: {
|
|
3854
|
+
type: "object",
|
|
3855
|
+
properties: {
|
|
3856
|
+
appId: { type: "string", description: "App Store app id" },
|
|
3857
|
+
productId: { type: "string", description: "The product's productId (e.g. com.app.weekly) or its ASC id" },
|
|
3858
|
+
usPrice: { type: "number", description: "Override the US base price (also becomes the new USA price on apply)" },
|
|
3859
|
+
coefficients: {
|
|
3860
|
+
type: "object",
|
|
3861
|
+
description: "Per-tier multiplier overrides, e.g. {\"emerging\":0.55}. Each 0.1–2.0. Categories: premium, high_income, upper_middle, lower_middle, emerging.",
|
|
3862
|
+
additionalProperties: { type: "number" },
|
|
3863
|
+
},
|
|
3864
|
+
exclude: {
|
|
3865
|
+
type: "array",
|
|
3866
|
+
description: "3-letter territory codes to exclude (e.g. [\"RUS\",\"BLR\"])",
|
|
3867
|
+
items: { type: "string" },
|
|
3868
|
+
},
|
|
3869
|
+
},
|
|
3870
|
+
required: ["appId", "productId"],
|
|
3871
|
+
},
|
|
3872
|
+
run: async (a) => {
|
|
3873
|
+
const r = await pppResolve(a);
|
|
3874
|
+
if (r.error) return r;
|
|
3875
|
+
const currencies = await pppFetchCurrencies();
|
|
3876
|
+
return {
|
|
3877
|
+
dryRun: true,
|
|
3878
|
+
product: { productId: r.product.productId, name: r.product.name, type: r.product.isSubscription ? "SUB" : "IAP" },
|
|
3879
|
+
usPrice: r.usPrice,
|
|
3880
|
+
usaTierPrice: r.usTier.customerPrice,
|
|
3881
|
+
coefficientOverrides: r.overrides,
|
|
3882
|
+
territoriesResolved: Object.keys(r.resolved).length,
|
|
3883
|
+
tiers: tierSummary(),
|
|
3884
|
+
prices: r.rows.map((row) => ({ ...row, currency: currencies[row.territory] })),
|
|
3885
|
+
note: "No changes were written. Review this table with the user, then call apply_ppp_prices with confirm:true to apply.",
|
|
3886
|
+
};
|
|
3887
|
+
},
|
|
3888
|
+
},
|
|
3889
|
+
{
|
|
3890
|
+
name: "apply_ppp_prices",
|
|
3891
|
+
description:
|
|
3892
|
+
"APPLY purchasing-power-parity regional prices for ONE in-app purchase or subscription across ~174 territories. IRREVERSIBLE and customer-facing. Requires confirm:true, and is blocked in read-only mode or when APPSTORE_MCP_ALLOW_PRICE_CHANGES=false. ALWAYS run preview_ppp_prices first and get the user's explicit go-ahead. " +
|
|
3893
|
+
"IAPs are set in a single atomic price-schedule request. Subscriptions are set per-territory; by default preserveCurrentPrice keeps existing subscribers on their current price and the change starts in 2 days (override with preserveCurrentPrice/startDate). Pending (future-dated) subscription prices are cleared first to avoid conflicts. Takes the same pricing inputs as preview_ppp_prices.",
|
|
3894
|
+
inputSchema: {
|
|
3895
|
+
type: "object",
|
|
3896
|
+
properties: {
|
|
3897
|
+
appId: { type: "string" },
|
|
3898
|
+
productId: { type: "string" },
|
|
3899
|
+
confirm: { type: "boolean", description: "Must be true. Safety gate acknowledging this changes real customer prices." },
|
|
3900
|
+
usPrice: { type: "number", description: "Override the US base price (also sets the new USA price)" },
|
|
3901
|
+
coefficients: {
|
|
3902
|
+
type: "object",
|
|
3903
|
+
description: "Per-tier multiplier overrides (same as preview_ppp_prices).",
|
|
3904
|
+
additionalProperties: { type: "number" },
|
|
3905
|
+
},
|
|
3906
|
+
exclude: { type: "array", items: { type: "string" } },
|
|
3907
|
+
preserveCurrentPrice: {
|
|
3908
|
+
type: "boolean",
|
|
3909
|
+
description: "Subscriptions only: keep existing subscribers on their current price (default true).",
|
|
3910
|
+
},
|
|
3911
|
+
startDate: {
|
|
3912
|
+
type: "string",
|
|
3913
|
+
description: "Subscriptions only: YYYY-MM-DD the new prices take effect (default: 2 days from now).",
|
|
3914
|
+
},
|
|
3915
|
+
},
|
|
3916
|
+
required: ["appId", "productId", "confirm"],
|
|
3917
|
+
},
|
|
3918
|
+
run: async (a) => {
|
|
3919
|
+
if (a.confirm !== true)
|
|
3920
|
+
return {
|
|
3921
|
+
error:
|
|
3922
|
+
"Refused: apply_ppp_prices needs confirm:true. Run preview_ppp_prices, show the user the table, get explicit approval, then re-call with confirm:true.",
|
|
3923
|
+
};
|
|
3924
|
+
// Default preserveCurrentPrice to true for subscriptions (safer for existing subscribers).
|
|
3925
|
+
const preserved = a.preserveCurrentPrice === undefined ? true : a.preserveCurrentPrice;
|
|
3926
|
+
const r = await pppResolve({ ...a, preserveCurrentPrice: preserved });
|
|
3927
|
+
if (r.error) return r;
|
|
3928
|
+
const result = await pppApply(r.product, r.resolved, {
|
|
3929
|
+
preserved,
|
|
3930
|
+
startDate: a.startDate,
|
|
3931
|
+
});
|
|
3932
|
+
return {
|
|
3933
|
+
applied: true,
|
|
3934
|
+
product: { productId: r.product.productId, name: r.product.name, type: r.product.isSubscription ? "SUB" : "IAP" },
|
|
3935
|
+
usPrice: r.usPrice,
|
|
3936
|
+
...result,
|
|
3937
|
+
};
|
|
3938
|
+
},
|
|
3939
|
+
},
|
|
3940
|
+
|
|
3941
|
+
// ---- iOS CI/CD → TestFlight bootstrap ----
|
|
3942
|
+
{
|
|
3943
|
+
name: "ensure_asc_app",
|
|
3944
|
+
description:
|
|
3945
|
+
"Find the App Store Connect app record for a bundle id. Returns {app_id, created, bundleId, name}. NOTE: the public App Store Connect API cannot CREATE an app record (there is no POST /apps) — so this is find-only and always returns created:false. If the app doesn't exist yet, it returns found:false plus guidance: register the bundle id (register_bundle_id) and create the record once in the App Store Connect web UI (the first TestFlight upload also can't create it).",
|
|
3946
|
+
inputSchema: {
|
|
3947
|
+
type: "object",
|
|
3948
|
+
properties: {
|
|
3949
|
+
bundleId: { type: "string", description: "e.g. com.example.app" },
|
|
3950
|
+
name: { type: "string", description: "Unused for lookup; echoed back for convenience." },
|
|
3951
|
+
sku: { type: "string" },
|
|
3952
|
+
platform: { type: "string", description: "IOS (default)" },
|
|
3953
|
+
primaryLocale: { type: "string" },
|
|
3954
|
+
},
|
|
3955
|
+
required: ["bundleId"],
|
|
3956
|
+
},
|
|
3957
|
+
run: async (a) => {
|
|
3958
|
+
const app = await findAppByBundleId(a.bundleId);
|
|
3959
|
+
if (app)
|
|
3960
|
+
return {
|
|
3961
|
+
app_id: app.id,
|
|
3962
|
+
created: false,
|
|
3963
|
+
found: true,
|
|
3964
|
+
bundleId: app.attributes?.bundleId,
|
|
3965
|
+
name: app.attributes?.name,
|
|
3966
|
+
};
|
|
3967
|
+
return {
|
|
3968
|
+
app_id: null,
|
|
3969
|
+
created: false,
|
|
3970
|
+
found: false,
|
|
3971
|
+
bundleId: a.bundleId,
|
|
3972
|
+
action_needed:
|
|
3973
|
+
"App record not found. The App Store Connect API cannot create it. Steps: 1) register the bundle id with register_bundle_id (if not registered), 2) create the app record in App Store Connect → My Apps → + → New App. Then re-run.",
|
|
3974
|
+
};
|
|
3975
|
+
},
|
|
3976
|
+
},
|
|
3977
|
+
{
|
|
3978
|
+
name: "bootstrap_ios_cicd",
|
|
3979
|
+
description:
|
|
3980
|
+
"Scaffold a fastlane + GitHub Actions → TestFlight pipeline into an iOS app's repo. Renders 7 files (Gemfile, fastlane/{Appfile,Fastfile,.gitignore,SETUP.md}, .github/workflows/{ios-ci.yml,ios-testflight.yml}) using Xcode automatic/cloud signing (-allowProvisioningUpdates, no match repo). Auto-detects appDir/bundleId/teamId/scheme/target from the repo's .xcodeproj when not given. mode: 'pr' (default; branch+push+open PR via gh), 'branch' (push only), 'commit' (commit locally), 'files' (write only). Use dryRun:true to preview detected values and rendered files without writing.",
|
|
3981
|
+
inputSchema: {
|
|
3982
|
+
type: "object",
|
|
3983
|
+
properties: {
|
|
3984
|
+
repoDir: { type: "string", description: "Local path to the repo working copy (default '.')" },
|
|
3985
|
+
repo: { type: "string", description: "GitHub 'owner/name' for the PR (default: derived from origin)" },
|
|
3986
|
+
owner: { type: "string", description: "GitHub owner, if repo is just a name" },
|
|
3987
|
+
appDir: { type: "string", description: "Dir containing the .xcodeproj, relative to repo root (auto-detected)" },
|
|
3988
|
+
bundleId: { type: "string", description: "Override auto-detected bundle id" },
|
|
3989
|
+
teamId: { type: "string", description: "Override auto-detected Apple Developer team id" },
|
|
3990
|
+
scheme: { type: "string", description: "Xcode scheme (default: project name)" },
|
|
3991
|
+
target: { type: "string", description: "Xcode target (default: scheme)" },
|
|
3992
|
+
mode: { type: "string", description: "pr (default), branch, commit, or files" },
|
|
3993
|
+
branch: { type: "string", description: "Branch name (default ci/ios-testflight-bootstrap)" },
|
|
3994
|
+
baseBranch: { type: "string", description: "PR base branch (default: current branch)" },
|
|
3995
|
+
dryRun: { type: "boolean", description: "Preview without writing (default false)" },
|
|
3996
|
+
},
|
|
3997
|
+
required: [],
|
|
3998
|
+
},
|
|
3999
|
+
run: async (a) => doBootstrap(a),
|
|
4000
|
+
},
|
|
4001
|
+
{
|
|
4002
|
+
name: "set_repo_ci_secrets",
|
|
4003
|
+
description:
|
|
4004
|
+
"Push the three CI secrets (ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_P8) to a GitHub repo's Actions secrets via `gh secret set`. The .p8 is base64-encoded. CRITICAL: the API key is read from THIS server's own configured environment — it is never accepted as an argument and never returned in output. gh handles the libsodium sealed-box encryption. Requires `gh` installed + authenticated with repo admin. Create the App Store Connect API key once at the TEAM level so the same secret values work for every repo.",
|
|
4005
|
+
inputSchema: {
|
|
4006
|
+
type: "object",
|
|
4007
|
+
properties: {
|
|
4008
|
+
repo: { type: "string", description: "GitHub 'owner/name' (or just the name with owner/repoDir)" },
|
|
4009
|
+
owner: { type: "string", description: "GitHub owner, if repo is just a name" },
|
|
4010
|
+
repoDir: { type: "string", description: "Local clone to derive owner/name from origin (default '.')" },
|
|
4011
|
+
},
|
|
4012
|
+
required: [],
|
|
4013
|
+
},
|
|
4014
|
+
run: async (a) => doSetSecrets(a),
|
|
4015
|
+
},
|
|
4016
|
+
{
|
|
4017
|
+
name: "bootstrap_testflight",
|
|
4018
|
+
description:
|
|
4019
|
+
"One-call orchestrator: turn a new iOS app into a TestFlight pipeline. Runs ensure_asc_app (find the app record) → bootstrap_ios_cicd (scaffold fastlane + Actions, open a PR) → set_repo_ci_secrets (push ASC_* secrets from this server's config). Auto-detects project settings. If the app record doesn't exist yet, it still scaffolds + sets secrets and tells you to create the record in the web UI. Pass the same options as bootstrap_ios_cicd; use dryRun:true to preview.",
|
|
4020
|
+
inputSchema: {
|
|
4021
|
+
type: "object",
|
|
4022
|
+
properties: {
|
|
4023
|
+
repoDir: { type: "string", description: "Local path to the repo (default '.')" },
|
|
4024
|
+
repo: { type: "string", description: "GitHub 'owner/name' (default: derived from origin)" },
|
|
4025
|
+
owner: { type: "string" },
|
|
4026
|
+
appDir: { type: "string" },
|
|
4027
|
+
bundleId: { type: "string", description: "Override auto-detected bundle id" },
|
|
4028
|
+
name: { type: "string", description: "App name (for the ensure_asc_app lookup echo)" },
|
|
4029
|
+
teamId: { type: "string" },
|
|
4030
|
+
scheme: { type: "string" },
|
|
4031
|
+
target: { type: "string" },
|
|
4032
|
+
mode: { type: "string", description: "pr (default), branch, commit, files" },
|
|
4033
|
+
dryRun: { type: "boolean", description: "Preview without writing or pushing (default false)" },
|
|
4034
|
+
},
|
|
4035
|
+
required: [],
|
|
4036
|
+
},
|
|
4037
|
+
run: async (a) => {
|
|
4038
|
+
const steps = {};
|
|
4039
|
+
|
|
4040
|
+
// 1. Scaffold (also resolves auto-detected bundleId we can reuse for the app lookup).
|
|
4041
|
+
steps.bootstrap = await doBootstrap(a);
|
|
4042
|
+
if (steps.bootstrap.error)
|
|
4043
|
+
return { ok: false, failedStep: "bootstrap_ios_cicd", ...steps };
|
|
4044
|
+
const bundleId = a.bundleId || steps.bootstrap.detected?.bundleId;
|
|
4045
|
+
|
|
4046
|
+
// 2. Find the ASC app record (find-only; never blocks the pipeline).
|
|
4047
|
+
try {
|
|
4048
|
+
const app = bundleId ? await findAppByBundleId(bundleId) : null;
|
|
4049
|
+
steps.app = app
|
|
4050
|
+
? { app_id: app.id, created: false, found: true, bundleId, name: app.attributes?.name }
|
|
4051
|
+
: {
|
|
4052
|
+
app_id: null,
|
|
4053
|
+
created: false,
|
|
4054
|
+
found: false,
|
|
4055
|
+
bundleId,
|
|
4056
|
+
action_needed:
|
|
4057
|
+
"App record not found — create it in App Store Connect → My Apps → New App (the API cannot create it). CI secrets and the pipeline are still set up; TestFlight uploads will work once the record exists.",
|
|
4058
|
+
};
|
|
4059
|
+
} catch (e) {
|
|
4060
|
+
steps.app = { error: e.message };
|
|
4061
|
+
}
|
|
4062
|
+
|
|
4063
|
+
if (a.dryRun) {
|
|
4064
|
+
steps.secrets = { skipped: "dryRun — secrets not pushed" };
|
|
4065
|
+
return { ok: true, dryRun: true, ...steps };
|
|
4066
|
+
}
|
|
4067
|
+
|
|
4068
|
+
// 3. Push the CI secrets.
|
|
4069
|
+
steps.secrets = await doSetSecrets(a);
|
|
4070
|
+
const ok = !steps.secrets.error;
|
|
4071
|
+
return { ok, ...steps };
|
|
4072
|
+
},
|
|
4073
|
+
},
|
|
4074
|
+
|
|
4075
|
+
// ---- Submission flow: build ↔ version ↔ review ----
|
|
4076
|
+
{
|
|
4077
|
+
name: "attach_build_to_version",
|
|
4078
|
+
description:
|
|
4079
|
+
"Attach an already-processed build to an App Store version — the mandatory step before submitting that has no other tool. Pass the build by its ASC id (buildId) or by its number (buildNumber, resolved within the app). Uses PATCH /appStoreVersions/{id}/relationships/build.",
|
|
4080
|
+
inputSchema: {
|
|
4081
|
+
type: "object",
|
|
4082
|
+
properties: {
|
|
4083
|
+
versionId: { type: "string" },
|
|
4084
|
+
buildId: { type: "string", description: "ASC build id" },
|
|
4085
|
+
buildNumber: { type: "string", description: "Build number (needs appId to resolve)" },
|
|
4086
|
+
appId: { type: "string", description: "Required when using buildNumber" },
|
|
4087
|
+
},
|
|
4088
|
+
required: ["versionId"],
|
|
4089
|
+
},
|
|
4090
|
+
run: async (a) => {
|
|
4091
|
+
if (!a.buildId && !a.buildNumber) return { error: "Pass buildId or buildNumber." };
|
|
4092
|
+
const build = a.buildId
|
|
4093
|
+
? await resolveBuild(a.appId, a.buildId)
|
|
4094
|
+
: await resolveBuild(a.appId, a.buildNumber);
|
|
4095
|
+
await client.patch(`/appStoreVersions/${a.versionId}/relationships/build`, {
|
|
4096
|
+
data: { type: "builds", id: build.id },
|
|
4097
|
+
});
|
|
4098
|
+
return {
|
|
4099
|
+
attached: true,
|
|
4100
|
+
versionId: a.versionId,
|
|
4101
|
+
buildId: build.id,
|
|
4102
|
+
buildNumber: build.attributes?.version,
|
|
4103
|
+
processingState: build.attributes?.processingState,
|
|
4104
|
+
};
|
|
4105
|
+
},
|
|
4106
|
+
},
|
|
4107
|
+
{
|
|
4108
|
+
name: "get_app_store_version",
|
|
4109
|
+
description:
|
|
4110
|
+
"Get one App Store version with its state and the build attached to it (GET /appStoreVersions/{id}?include=build). Returns appStoreState, releaseType, versionString, usesIdfa, and the attached build's number/processing state (or null if none).",
|
|
4111
|
+
inputSchema: {
|
|
4112
|
+
type: "object",
|
|
4113
|
+
properties: { versionId: { type: "string" } },
|
|
4114
|
+
required: ["versionId"],
|
|
4115
|
+
},
|
|
4116
|
+
run: async (a) => {
|
|
4117
|
+
const v = await client.get(`/appStoreVersions/${a.versionId}`, { include: "build" });
|
|
4118
|
+
const attrs = v.data?.attributes || {};
|
|
4119
|
+
const buildRel = v.data?.relationships?.build?.data;
|
|
4120
|
+
let build = null;
|
|
4121
|
+
if (buildRel) {
|
|
4122
|
+
const inc = (v.included || []).find((i) => i.type === "builds" && i.id === buildRel.id);
|
|
4123
|
+
build = inc
|
|
4124
|
+
? { id: inc.id, version: inc.attributes?.version, processingState: inc.attributes?.processingState }
|
|
4125
|
+
: { id: buildRel.id };
|
|
4126
|
+
}
|
|
4127
|
+
return { id: a.versionId, ...attrs, build };
|
|
4128
|
+
},
|
|
4129
|
+
},
|
|
4130
|
+
{
|
|
4131
|
+
name: "update_app_store_version",
|
|
4132
|
+
description:
|
|
4133
|
+
"Update App Store version attributes: usesIdfa (unset → opaque 409 at submit), releaseType (MANUAL, AFTER_APPROVAL, SCHEDULED), earliestReleaseDate (ISO 8601), versionString, downloadable. Only pass fields you want to change.",
|
|
4134
|
+
inputSchema: {
|
|
4135
|
+
type: "object",
|
|
4136
|
+
properties: {
|
|
4137
|
+
versionId: { type: "string" },
|
|
4138
|
+
usesIdfa: { type: "boolean" },
|
|
4139
|
+
releaseType: { type: "string", description: "MANUAL, AFTER_APPROVAL, SCHEDULED" },
|
|
4140
|
+
earliestReleaseDate: { type: "string", description: "ISO 8601 (for SCHEDULED)" },
|
|
4141
|
+
versionString: { type: "string" },
|
|
4142
|
+
downloadable: { type: "boolean" },
|
|
4143
|
+
},
|
|
4144
|
+
required: ["versionId"],
|
|
4145
|
+
},
|
|
4146
|
+
run: async (a) => {
|
|
4147
|
+
const attributes = {};
|
|
4148
|
+
for (const k of ["usesIdfa", "releaseType", "earliestReleaseDate", "versionString", "downloadable"])
|
|
4149
|
+
if (a[k] !== undefined) attributes[k] = a[k];
|
|
4150
|
+
if (!Object.keys(attributes).length) return { error: "No attributes to update." };
|
|
4151
|
+
return client.patch(`/appStoreVersions/${a.versionId}`, {
|
|
4152
|
+
data: { type: "appStoreVersions", id: a.versionId, attributes },
|
|
4153
|
+
});
|
|
4154
|
+
},
|
|
4155
|
+
},
|
|
4156
|
+
{
|
|
4157
|
+
name: "update_build",
|
|
4158
|
+
description:
|
|
4159
|
+
"Update a build's attributes. usesNonExemptEncryption sets export compliance — this is PER-BUILD and does NOT carry over from a previous build; when unset, submit_for_review fails with an opaque 409. expired:true expires a TestFlight build. Pass buildId or buildNumber(+appId).",
|
|
4160
|
+
inputSchema: {
|
|
4161
|
+
type: "object",
|
|
4162
|
+
properties: {
|
|
4163
|
+
buildId: { type: "string" },
|
|
4164
|
+
buildNumber: { type: "string" },
|
|
4165
|
+
appId: { type: "string" },
|
|
4166
|
+
usesNonExemptEncryption: { type: "boolean" },
|
|
4167
|
+
expired: { type: "boolean" },
|
|
4168
|
+
},
|
|
4169
|
+
},
|
|
4170
|
+
run: async (a) => {
|
|
4171
|
+
const build = await resolveBuild(a.appId, a.buildId || a.buildNumber);
|
|
4172
|
+
const attributes = {};
|
|
4173
|
+
if (a.usesNonExemptEncryption !== undefined) attributes.usesNonExemptEncryption = a.usesNonExemptEncryption;
|
|
4174
|
+
if (a.expired !== undefined) attributes.expired = a.expired;
|
|
4175
|
+
if (!Object.keys(attributes).length) return { error: "Pass usesNonExemptEncryption and/or expired." };
|
|
4176
|
+
const res = await client.patch(`/builds/${build.id}`, {
|
|
4177
|
+
data: { type: "builds", id: build.id, attributes },
|
|
4178
|
+
});
|
|
4179
|
+
return { buildId: build.id, updated: attributes, result: res.data?.attributes };
|
|
4180
|
+
},
|
|
4181
|
+
},
|
|
4182
|
+
{
|
|
4183
|
+
name: "expire_build",
|
|
4184
|
+
description: "Expire a TestFlight build (PATCH /builds/{id} {expired:true}). Pass buildId or buildNumber(+appId).",
|
|
4185
|
+
inputSchema: {
|
|
4186
|
+
type: "object",
|
|
4187
|
+
properties: {
|
|
4188
|
+
buildId: { type: "string" },
|
|
4189
|
+
buildNumber: { type: "string" },
|
|
4190
|
+
appId: { type: "string" },
|
|
4191
|
+
},
|
|
4192
|
+
},
|
|
4193
|
+
run: async (a) => {
|
|
4194
|
+
const build = await resolveBuild(a.appId, a.buildId || a.buildNumber);
|
|
4195
|
+
await client.patch(`/builds/${build.id}`, {
|
|
4196
|
+
data: { type: "builds", id: build.id, attributes: { expired: true } },
|
|
4197
|
+
});
|
|
4198
|
+
return { buildId: build.id, buildNumber: build.attributes?.version, expired: true };
|
|
4199
|
+
},
|
|
4200
|
+
},
|
|
4201
|
+
{
|
|
4202
|
+
name: "get_build",
|
|
4203
|
+
description:
|
|
4204
|
+
"Get one build by its number (buildNumber + appId) or ASC id (buildId). Returns version, processingState, uploaded/expiration dates, min OS, export compliance.",
|
|
4205
|
+
inputSchema: {
|
|
4206
|
+
type: "object",
|
|
4207
|
+
properties: {
|
|
4208
|
+
appId: { type: "string" },
|
|
4209
|
+
buildNumber: { type: "string" },
|
|
4210
|
+
buildId: { type: "string" },
|
|
4211
|
+
},
|
|
4212
|
+
},
|
|
4213
|
+
run: async (a) => {
|
|
4214
|
+
const build = await resolveBuild(a.appId, a.buildId || a.buildNumber);
|
|
4215
|
+
return { id: build.id, ...build.attributes };
|
|
4216
|
+
},
|
|
4217
|
+
},
|
|
4218
|
+
{
|
|
4219
|
+
name: "wait_for_build_processing",
|
|
4220
|
+
description:
|
|
4221
|
+
"Poll until a build finishes processing (VALID) or fails (INVALID/FAILED), or the timeout elapses. Use right after upload_build to confirm a build is ready before attaching it. Returns the final state and build id.",
|
|
4222
|
+
inputSchema: {
|
|
4223
|
+
type: "object",
|
|
4224
|
+
properties: {
|
|
4225
|
+
appId: { type: "string" },
|
|
4226
|
+
buildNumber: { type: "string" },
|
|
4227
|
+
timeoutSeconds: { type: "number", description: "Max wait (default 600)" },
|
|
4228
|
+
pollSeconds: { type: "number", description: "Poll interval (default 15)" },
|
|
4229
|
+
},
|
|
4230
|
+
required: ["appId", "buildNumber"],
|
|
4231
|
+
},
|
|
4232
|
+
run: async (a) => {
|
|
4233
|
+
const timeout = (a.timeoutSeconds ?? 600) * 1000;
|
|
4234
|
+
const poll = Math.max((a.pollSeconds ?? 15) * 1000, 5000);
|
|
4235
|
+
const deadline = Date.now() + timeout;
|
|
4236
|
+
const terminal = new Set(["VALID", "INVALID", "FAILED"]);
|
|
4237
|
+
let last = null;
|
|
4238
|
+
while (Date.now() < deadline) {
|
|
4239
|
+
const page = await client.get(`/builds`, {
|
|
4240
|
+
"filter[app]": a.appId,
|
|
4241
|
+
"filter[version]": String(a.buildNumber),
|
|
4242
|
+
limit: 1,
|
|
4243
|
+
});
|
|
4244
|
+
const b = page.data?.[0];
|
|
4245
|
+
if (b) {
|
|
4246
|
+
last = b;
|
|
4247
|
+
const state = b.attributes?.processingState;
|
|
4248
|
+
if (terminal.has(state))
|
|
4249
|
+
return { buildId: b.id, buildNumber: b.attributes?.version, processingState: state, done: true };
|
|
4250
|
+
}
|
|
4251
|
+
await new Promise((r) => setTimeout(r, poll));
|
|
4252
|
+
}
|
|
4253
|
+
return {
|
|
4254
|
+
done: false,
|
|
4255
|
+
timedOut: true,
|
|
4256
|
+
buildId: last?.id || null,
|
|
4257
|
+
processingState: last?.attributes?.processingState || "UNKNOWN",
|
|
4258
|
+
note: "Build did not reach a terminal state before the timeout. Call again or increase timeoutSeconds.",
|
|
4259
|
+
};
|
|
4260
|
+
},
|
|
4261
|
+
},
|
|
4262
|
+
{
|
|
4263
|
+
name: "next_build_number",
|
|
4264
|
+
description:
|
|
4265
|
+
"Return the highest existing build number for an app and the suggested next one (+1), so you don't have to list every build before archiving.",
|
|
4266
|
+
inputSchema: {
|
|
4267
|
+
type: "object",
|
|
4268
|
+
properties: { appId: { type: "string" } },
|
|
4269
|
+
required: ["appId"],
|
|
4270
|
+
},
|
|
4271
|
+
run: async (a) => {
|
|
4272
|
+
const page = await client.get(`/builds`, {
|
|
4273
|
+
"filter[app]": a.appId,
|
|
4274
|
+
sort: "-version",
|
|
4275
|
+
limit: 20,
|
|
4276
|
+
});
|
|
4277
|
+
const nums = (page.data || [])
|
|
4278
|
+
.map((b) => parseInt(b.attributes?.version, 10))
|
|
4279
|
+
.filter((n) => Number.isFinite(n));
|
|
4280
|
+
const latest = nums.length ? Math.max(...nums) : 0;
|
|
4281
|
+
return { latestBuildNumber: latest || null, nextBuildNumber: latest + 1 };
|
|
4282
|
+
},
|
|
4283
|
+
},
|
|
4284
|
+
{
|
|
4285
|
+
name: "list_review_submissions",
|
|
4286
|
+
description:
|
|
4287
|
+
"List App Store review submissions for an app (with their items), newest-relevant first. Use to find an in-flight submission to cancel when swapping a build. Filter by state (e.g. READY_FOR_REVIEW, WAITING_FOR_REVIEW, IN_REVIEW).",
|
|
4288
|
+
inputSchema: {
|
|
4289
|
+
type: "object",
|
|
4290
|
+
properties: {
|
|
4291
|
+
appId: { type: "string" },
|
|
4292
|
+
state: { type: "string", description: "Optional reviewSubmissionState filter" },
|
|
4293
|
+
},
|
|
4294
|
+
required: ["appId"],
|
|
4295
|
+
},
|
|
4296
|
+
run: async (a) => {
|
|
4297
|
+
const q = { "filter[app]": a.appId, include: "items" };
|
|
4298
|
+
if (a.state) q["filter[state]"] = a.state;
|
|
4299
|
+
const { data } = await client.getAllPages(`/reviewSubmissions`, q);
|
|
4300
|
+
return data.map((x) => ({
|
|
4301
|
+
id: x.id,
|
|
4302
|
+
state: x.attributes?.state,
|
|
4303
|
+
platform: x.attributes?.platform,
|
|
4304
|
+
submitted: x.attributes?.submittedDate,
|
|
4305
|
+
itemCount: x.relationships?.items?.data?.length ?? null,
|
|
4306
|
+
canceled: x.attributes?.canceled,
|
|
4307
|
+
}));
|
|
4308
|
+
},
|
|
4309
|
+
},
|
|
4310
|
+
{
|
|
4311
|
+
name: "get_review_submission",
|
|
4312
|
+
description: "Get one review submission by id, including its app, the version under review, and its items.",
|
|
4313
|
+
inputSchema: {
|
|
4314
|
+
type: "object",
|
|
4315
|
+
properties: { id: { type: "string" } },
|
|
4316
|
+
required: ["id"],
|
|
4317
|
+
},
|
|
4318
|
+
run: async (a) =>
|
|
4319
|
+
client.get(`/reviewSubmissions/${a.id}`, {
|
|
4320
|
+
include: "app,appStoreVersionForReview,items",
|
|
4321
|
+
}),
|
|
4322
|
+
},
|
|
4323
|
+
{
|
|
4324
|
+
name: "add_review_submission_item",
|
|
4325
|
+
description:
|
|
4326
|
+
"Add an App Store version to an existing (open) review submission. POST /reviewSubmissionItems.",
|
|
4327
|
+
inputSchema: {
|
|
4328
|
+
type: "object",
|
|
4329
|
+
properties: {
|
|
4330
|
+
reviewSubmissionId: { type: "string" },
|
|
4331
|
+
versionId: { type: "string" },
|
|
4332
|
+
},
|
|
4333
|
+
required: ["reviewSubmissionId", "versionId"],
|
|
4334
|
+
},
|
|
4335
|
+
run: async (a) =>
|
|
4336
|
+
client.post(`/reviewSubmissionItems`, {
|
|
4337
|
+
data: {
|
|
4338
|
+
type: "reviewSubmissionItems",
|
|
4339
|
+
relationships: {
|
|
4340
|
+
reviewSubmission: { data: { type: "reviewSubmissions", id: a.reviewSubmissionId } },
|
|
4341
|
+
appStoreVersion: { data: { type: "appStoreVersions", id: a.versionId } },
|
|
4342
|
+
},
|
|
4343
|
+
},
|
|
4344
|
+
}),
|
|
4345
|
+
},
|
|
4346
|
+
{
|
|
4347
|
+
name: "cancel_review_submission",
|
|
4348
|
+
description:
|
|
4349
|
+
"Cancel (pull back) a review submission that's in review — required before you can attach a different build. PATCH /reviewSubmissions/{id} {canceled:true}. Apple refuses cancel on an empty (0-item) submission and on submissions not in a cancellable state; this surfaces that cleanly. Pass submissionId, or appId to cancel the app's current in-flight submission. Optionally waitSeconds to poll until it reaches a terminal state.",
|
|
4350
|
+
inputSchema: {
|
|
4351
|
+
type: "object",
|
|
4352
|
+
properties: {
|
|
4353
|
+
submissionId: { type: "string" },
|
|
4354
|
+
appId: { type: "string", description: "Cancel the app's current in-flight submission (if submissionId omitted)" },
|
|
4355
|
+
waitSeconds: { type: "number", description: "Poll until canceled/complete, up to this long" },
|
|
4356
|
+
},
|
|
4357
|
+
},
|
|
4358
|
+
run: async (a) => {
|
|
4359
|
+
let id = a.submissionId;
|
|
4360
|
+
if (!id) {
|
|
4361
|
+
if (!a.appId) return { error: "Pass submissionId or appId." };
|
|
4362
|
+
const { data } = await client.getAllPages(`/reviewSubmissions`, {
|
|
4363
|
+
"filter[app]": a.appId,
|
|
4364
|
+
include: "items",
|
|
4365
|
+
});
|
|
4366
|
+
const active = data.find((s) =>
|
|
4367
|
+
["READY_FOR_REVIEW", "WAITING_FOR_REVIEW", "IN_REVIEW", "UNRESOLVED_ISSUES"].includes(
|
|
4368
|
+
s.attributes?.state,
|
|
4369
|
+
),
|
|
4370
|
+
);
|
|
4371
|
+
if (!active)
|
|
4372
|
+
return { error: "No in-flight review submission found for this app." };
|
|
4373
|
+
id = active.id;
|
|
4374
|
+
}
|
|
4375
|
+
try {
|
|
4376
|
+
const res = await client.patch(`/reviewSubmissions/${id}`, {
|
|
4377
|
+
data: { type: "reviewSubmissions", id, attributes: { canceled: true } },
|
|
4378
|
+
});
|
|
4379
|
+
let state = res.data?.attributes?.state;
|
|
4380
|
+
if (a.waitSeconds) {
|
|
4381
|
+
const deadline = Date.now() + a.waitSeconds * 1000;
|
|
4382
|
+
while (Date.now() < deadline && state !== "COMPLETE" && state !== "CANCELING") {
|
|
4383
|
+
await new Promise((r) => setTimeout(r, 10000));
|
|
4384
|
+
const cur = await client.get(`/reviewSubmissions/${id}`);
|
|
4385
|
+
state = cur.data?.attributes?.state;
|
|
4386
|
+
if (state === "COMPLETE") break;
|
|
4387
|
+
}
|
|
4388
|
+
}
|
|
4389
|
+
return { canceled: true, submissionId: id, state };
|
|
4390
|
+
} catch (e) {
|
|
4391
|
+
return {
|
|
4392
|
+
canceled: false,
|
|
4393
|
+
submissionId: id,
|
|
4394
|
+
error: e.message,
|
|
4395
|
+
hint: "Apple refuses cancel on an empty (0-item) or non-cancellable submission. Check its state with get_review_submission.",
|
|
4396
|
+
};
|
|
4397
|
+
}
|
|
4398
|
+
},
|
|
4399
|
+
},
|
|
4400
|
+
{
|
|
4401
|
+
name: "get_app_store_review_detail",
|
|
4402
|
+
description:
|
|
4403
|
+
"Get the App Store review contact details and notes for a version (GET /appStoreVersions/{id}/appStoreReviewDetail): contact name/email/phone, demo account, review notes.",
|
|
4404
|
+
inputSchema: {
|
|
4405
|
+
type: "object",
|
|
4406
|
+
properties: { versionId: { type: "string" } },
|
|
4407
|
+
required: ["versionId"],
|
|
4408
|
+
},
|
|
4409
|
+
run: async (a) => {
|
|
4410
|
+
try {
|
|
4411
|
+
return await client.get(`/appStoreVersions/${a.versionId}/appStoreReviewDetail`);
|
|
4412
|
+
} catch (e) {
|
|
4413
|
+
if (e.status === 404) return { note: "No review detail set for this version yet.", versionId: a.versionId };
|
|
4414
|
+
throw e;
|
|
4415
|
+
}
|
|
4416
|
+
},
|
|
4417
|
+
},
|
|
4418
|
+
{
|
|
4419
|
+
name: "get_beta_review_status",
|
|
4420
|
+
description:
|
|
4421
|
+
"Get the TestFlight beta review status for a build (its betaAppReviewSubmission state: WAITING_FOR_REVIEW, IN_REVIEW, APPROVED, REJECTED). Pass buildId or buildNumber(+appId).",
|
|
4422
|
+
inputSchema: {
|
|
4423
|
+
type: "object",
|
|
4424
|
+
properties: {
|
|
4425
|
+
buildId: { type: "string" },
|
|
4426
|
+
buildNumber: { type: "string" },
|
|
4427
|
+
appId: { type: "string" },
|
|
4428
|
+
},
|
|
4429
|
+
},
|
|
4430
|
+
run: async (a) => {
|
|
4431
|
+
const build = await resolveBuild(a.appId, a.buildId || a.buildNumber);
|
|
4432
|
+
try {
|
|
4433
|
+
const res = await client.get(`/builds/${build.id}/betaAppReviewSubmission`);
|
|
4434
|
+
return { buildId: build.id, buildNumber: build.attributes?.version, betaReviewState: res.data?.attributes?.betaReviewState };
|
|
4435
|
+
} catch (e) {
|
|
4436
|
+
if (e.status === 404)
|
|
4437
|
+
return { buildId: build.id, betaReviewState: null, note: "No beta review submission for this build." };
|
|
4438
|
+
throw e;
|
|
4439
|
+
}
|
|
4440
|
+
},
|
|
4441
|
+
},
|
|
4442
|
+
{
|
|
4443
|
+
name: "set_beta_build_notes",
|
|
4444
|
+
description:
|
|
4445
|
+
"Set the TestFlight \"What to Test\" notes for a build in a locale (betaBuildLocalizations — upserts the locale's whatsNew). Pass buildId or buildNumber(+appId).",
|
|
4446
|
+
inputSchema: {
|
|
4447
|
+
type: "object",
|
|
4448
|
+
properties: {
|
|
4449
|
+
buildId: { type: "string" },
|
|
4450
|
+
buildNumber: { type: "string" },
|
|
4451
|
+
appId: { type: "string" },
|
|
4452
|
+
locale: { type: "string", description: "e.g. 'en-US' (default)" },
|
|
4453
|
+
whatsNew: { type: "string", description: "The 'What to Test' text" },
|
|
4454
|
+
},
|
|
4455
|
+
required: ["whatsNew"],
|
|
4456
|
+
},
|
|
4457
|
+
run: async (a) => {
|
|
4458
|
+
const build = await resolveBuild(a.appId, a.buildId || a.buildNumber);
|
|
4459
|
+
const locale = a.locale || "en-US";
|
|
4460
|
+
const existing = await client.getAll(`/builds/${build.id}/betaBuildLocalizations`);
|
|
4461
|
+
const row = existing.find((x) => x.attributes?.locale === locale);
|
|
4462
|
+
if (row) {
|
|
4463
|
+
const res = await client.patch(`/betaBuildLocalizations/${row.id}`, {
|
|
4464
|
+
data: { type: "betaBuildLocalizations", id: row.id, attributes: { whatsNew: a.whatsNew } },
|
|
4465
|
+
});
|
|
4466
|
+
return { buildId: build.id, locale, updated: true, id: row.id, result: res.data?.attributes };
|
|
4467
|
+
}
|
|
4468
|
+
const res = await client.post(`/betaBuildLocalizations`, {
|
|
4469
|
+
data: {
|
|
4470
|
+
type: "betaBuildLocalizations",
|
|
4471
|
+
attributes: { locale, whatsNew: a.whatsNew },
|
|
4472
|
+
relationships: { build: { data: { type: "builds", id: build.id } } },
|
|
4473
|
+
},
|
|
4474
|
+
});
|
|
4475
|
+
return { buildId: build.id, locale, created: true, id: res.data?.id };
|
|
4476
|
+
},
|
|
4477
|
+
},
|
|
4478
|
+
|
|
4479
|
+
// ---- Screenshots: bulk + ordering + audit ----
|
|
4480
|
+
{
|
|
4481
|
+
name: "find_incomplete_screenshots",
|
|
4482
|
+
description:
|
|
4483
|
+
"For a version, list every screenshot and preview across all locales/sizes whose assetDeliveryState.state is not COMPLETE. These silently block submission (ASC shows \"uploads in progress\"). Read-only.",
|
|
4484
|
+
inputSchema: {
|
|
4485
|
+
type: "object",
|
|
4486
|
+
properties: { versionId: { type: "string" } },
|
|
4487
|
+
required: ["versionId"],
|
|
4488
|
+
},
|
|
4489
|
+
run: async (a) => {
|
|
4490
|
+
const stuck = await findIncompleteAssets(a.versionId);
|
|
4491
|
+
return {
|
|
4492
|
+
versionId: a.versionId,
|
|
4493
|
+
incompleteScreenshots: stuck.screenshots,
|
|
4494
|
+
incompletePreviews: stuck.previews,
|
|
4495
|
+
allComplete: stuck.screenshots.length === 0 && stuck.previews.length === 0,
|
|
4496
|
+
};
|
|
4497
|
+
},
|
|
4498
|
+
},
|
|
4499
|
+
{
|
|
4500
|
+
name: "reorder_screenshots",
|
|
4501
|
+
description:
|
|
4502
|
+
"Set the display order of screenshots within a set (new uploads always append last, so call this after re-uploading). Pass the full ordered list of screenshot ids. PATCH /appScreenshotSets/{id}/relationships/appScreenshots.",
|
|
4503
|
+
inputSchema: {
|
|
4504
|
+
type: "object",
|
|
4505
|
+
properties: {
|
|
4506
|
+
screenshotSetId: { type: "string" },
|
|
4507
|
+
orderedIds: { type: "array", items: { type: "string" }, description: "All screenshot ids in the desired order" },
|
|
4508
|
+
},
|
|
4509
|
+
required: ["screenshotSetId", "orderedIds"],
|
|
4510
|
+
},
|
|
4511
|
+
run: async (a) => {
|
|
4512
|
+
await client.patch(`/appScreenshotSets/${a.screenshotSetId}/relationships/appScreenshots`, {
|
|
4513
|
+
data: a.orderedIds.map((id) => ({ type: "appScreenshots", id })),
|
|
4514
|
+
});
|
|
4515
|
+
return { screenshotSetId: a.screenshotSetId, order: a.orderedIds };
|
|
4516
|
+
},
|
|
4517
|
+
},
|
|
4518
|
+
{
|
|
4519
|
+
name: "replace_screenshots",
|
|
4520
|
+
description:
|
|
4521
|
+
"Replace all screenshots in a set: delete the existing ones, then upload the given files in order (one call instead of deleting and uploading each by hand). Provide absolute paths to PNG/JPEG files sized for the set's device type.",
|
|
4522
|
+
inputSchema: {
|
|
4523
|
+
type: "object",
|
|
4524
|
+
properties: {
|
|
4525
|
+
screenshotSetId: { type: "string" },
|
|
4526
|
+
filePaths: { type: "array", items: { type: "string" }, description: "Absolute paths, in display order" },
|
|
4527
|
+
},
|
|
4528
|
+
required: ["screenshotSetId", "filePaths"],
|
|
4529
|
+
},
|
|
4530
|
+
run: async (a) => {
|
|
4531
|
+
const existing = await client.getAll(`/appScreenshotSets/${a.screenshotSetId}/appScreenshots`);
|
|
4532
|
+
let deleted = 0;
|
|
4533
|
+
for (const s of existing) {
|
|
4534
|
+
await client.delete(`/appScreenshots/${s.id}`);
|
|
4535
|
+
deleted++;
|
|
4536
|
+
}
|
|
4537
|
+
const uploaded = [];
|
|
4538
|
+
for (const fp of a.filePaths) {
|
|
4539
|
+
if (!existsSync(fp)) return { error: `File not found: ${fp}`, deleted, uploaded };
|
|
4540
|
+
uploaded.push(await uploadScreenshotFile(a.screenshotSetId, fp));
|
|
4541
|
+
}
|
|
4542
|
+
// New uploads preserve insertion order already, but set it explicitly.
|
|
4543
|
+
if (uploaded.length > 1)
|
|
4544
|
+
await client.patch(`/appScreenshotSets/${a.screenshotSetId}/relationships/appScreenshots`, {
|
|
4545
|
+
data: uploaded.map((u) => ({ type: "appScreenshots", id: u.id })),
|
|
4546
|
+
});
|
|
4547
|
+
return { screenshotSetId: a.screenshotSetId, deleted, uploaded };
|
|
4548
|
+
},
|
|
4549
|
+
},
|
|
4550
|
+
|
|
4551
|
+
// ---- Territories ----
|
|
4552
|
+
{
|
|
4553
|
+
name: "list_app_territories",
|
|
4554
|
+
description:
|
|
4555
|
+
"Compact list of the territories an app is available in, plus whether new territories are added automatically. Summarizes appAvailabilityV2 (which is huge to dump raw).",
|
|
4556
|
+
inputSchema: {
|
|
4557
|
+
type: "object",
|
|
4558
|
+
properties: { appId: { type: "string" } },
|
|
4559
|
+
required: ["appId"],
|
|
4560
|
+
},
|
|
4561
|
+
run: async (a) => {
|
|
4562
|
+
try {
|
|
4563
|
+
const { data, included } = await client.getAllPages(
|
|
4564
|
+
`/apps/${a.appId}/appAvailabilityV2`,
|
|
4565
|
+
{ include: "territoryAvailabilities", "limit[territoryAvailabilities]": 200 },
|
|
4566
|
+
);
|
|
4567
|
+
const root = Array.isArray(data) ? data[0] : data;
|
|
4568
|
+
const avail = included.filter((i) => i.type === "territoryAvailabilities");
|
|
4569
|
+
const codes = avail
|
|
4570
|
+
.filter((t) => t.attributes?.available !== false)
|
|
4571
|
+
.map((t) => t.relationships?.territory?.data?.id)
|
|
4572
|
+
.filter(Boolean)
|
|
4573
|
+
.sort();
|
|
4574
|
+
return {
|
|
4575
|
+
appId: a.appId,
|
|
4576
|
+
availableInNewTerritories: root?.attributes?.availableInNewTerritories,
|
|
4577
|
+
territoryCount: codes.length,
|
|
4578
|
+
territories: codes,
|
|
4579
|
+
};
|
|
4580
|
+
} catch (e) {
|
|
4581
|
+
// Fallback to the older availableTerritories relationship.
|
|
4582
|
+
const data = await client.getAll(`/apps/${a.appId}/availableTerritories`, { limit: 200 });
|
|
4583
|
+
const codes = data.map((t) => t.id).sort();
|
|
4584
|
+
return { appId: a.appId, territoryCount: codes.length, territories: codes, note: "via availableTerritories" };
|
|
4585
|
+
}
|
|
4586
|
+
},
|
|
4587
|
+
},
|
|
4588
|
+
|
|
4589
|
+
// ---- Subscriptions (groups, subscriptions, offers) ----
|
|
4590
|
+
{
|
|
4591
|
+
name: "list_subscription_groups",
|
|
4592
|
+
description:
|
|
4593
|
+
"List an app's subscription groups (referenceName + id). Use a group id with list_subscriptions.",
|
|
4594
|
+
inputSchema: {
|
|
4595
|
+
type: "object",
|
|
4596
|
+
properties: { appId: { type: "string" } },
|
|
4597
|
+
required: ["appId"],
|
|
4598
|
+
},
|
|
4599
|
+
run: async (a) => {
|
|
4600
|
+
const data = await client.getAll(`/apps/${a.appId}/subscriptionGroups`, { limit: 200 });
|
|
4601
|
+
return data.map((x) => ({ id: x.id, ...x.attributes }));
|
|
4602
|
+
},
|
|
4603
|
+
},
|
|
4604
|
+
{
|
|
4605
|
+
name: "list_subscriptions",
|
|
4606
|
+
description:
|
|
4607
|
+
"List the subscriptions in a subscription group (name, productId, state, subscriptionPeriod).",
|
|
4608
|
+
inputSchema: {
|
|
4609
|
+
type: "object",
|
|
4610
|
+
properties: { groupId: { type: "string" } },
|
|
4611
|
+
required: ["groupId"],
|
|
4612
|
+
},
|
|
4613
|
+
run: async (a) => {
|
|
4614
|
+
const data = await client.getAll(`/subscriptionGroups/${a.groupId}/subscriptions`, { limit: 200 });
|
|
4615
|
+
return data.map((x) => ({ id: x.id, ...x.attributes }));
|
|
4616
|
+
},
|
|
4617
|
+
},
|
|
4618
|
+
{
|
|
4619
|
+
name: "list_subscription_offers",
|
|
4620
|
+
description:
|
|
4621
|
+
"List a subscription's introductory, promotional and win-back offers, and FLAG any whose active date ranges OVERLAP — overlapping offers make StoreKit drop the product (seen as a sandbox countMismatch). Read-only.",
|
|
4622
|
+
inputSchema: {
|
|
4623
|
+
type: "object",
|
|
4624
|
+
properties: { subscriptionId: { type: "string" } },
|
|
4625
|
+
required: ["subscriptionId"],
|
|
4626
|
+
},
|
|
4627
|
+
run: async (a) => {
|
|
4628
|
+
const kinds = [
|
|
4629
|
+
["introductory", "introductoryOffers"],
|
|
4630
|
+
["promotional", "promotionalOffers"],
|
|
4631
|
+
["winBack", "winBackOffers"],
|
|
4632
|
+
];
|
|
4633
|
+
const offers = [];
|
|
4634
|
+
for (const [kind, rel] of kinds) {
|
|
4635
|
+
try {
|
|
4636
|
+
const data = await client.getAll(`/subscriptions/${a.subscriptionId}/${rel}`, { limit: 200 });
|
|
4637
|
+
for (const o of data)
|
|
4638
|
+
offers.push({ id: o.id, kind, ...o.attributes });
|
|
4639
|
+
} catch {
|
|
4640
|
+
/* some offer kinds may be unavailable */
|
|
4641
|
+
}
|
|
4642
|
+
}
|
|
4643
|
+
const overlaps = detectOverlappingOffers(offers);
|
|
4644
|
+
return {
|
|
4645
|
+
subscriptionId: a.subscriptionId,
|
|
4646
|
+
offerCount: offers.length,
|
|
4647
|
+
offers,
|
|
4648
|
+
overlappingOffers: overlaps,
|
|
4649
|
+
warning: overlaps.length
|
|
4650
|
+
? `${overlaps.length} overlapping offer pair(s) — this can make StoreKit drop the product in sandbox. Remove or re-date the overlaps.`
|
|
4651
|
+
: null,
|
|
4652
|
+
};
|
|
4653
|
+
},
|
|
4654
|
+
},
|
|
4655
|
+
{
|
|
4656
|
+
name: "create_subscription_group",
|
|
4657
|
+
description:
|
|
4658
|
+
"Create a subscription group for an app (POST /subscriptionGroups). referenceName is internal only. Add subscriptions with create_subscription.",
|
|
4659
|
+
inputSchema: {
|
|
4660
|
+
type: "object",
|
|
4661
|
+
properties: {
|
|
4662
|
+
appId: { type: "string" },
|
|
4663
|
+
referenceName: { type: "string" },
|
|
4664
|
+
},
|
|
4665
|
+
required: ["appId", "referenceName"],
|
|
4666
|
+
},
|
|
4667
|
+
run: async (a) =>
|
|
4668
|
+
client.post(`/subscriptionGroups`, {
|
|
4669
|
+
data: {
|
|
4670
|
+
type: "subscriptionGroups",
|
|
4671
|
+
attributes: { referenceName: a.referenceName },
|
|
4672
|
+
relationships: { app: { data: { type: "apps", id: a.appId } } },
|
|
4673
|
+
},
|
|
4674
|
+
}),
|
|
4675
|
+
},
|
|
4676
|
+
{
|
|
4677
|
+
name: "create_subscription",
|
|
4678
|
+
description:
|
|
4679
|
+
"Create an auto-renewable subscription in a group (POST /subscriptions). subscriptionPeriod: ONE_WEEK, ONE_MONTH, TWO_MONTHS, THREE_MONTHS, SIX_MONTHS, ONE_YEAR. productId must be globally unique. Localizations, prices (see apply_ppp_prices) and review screenshot are separate steps. NOTE: a first-time subscription must be ticked for review on the version page — the API can't submit it.",
|
|
4680
|
+
inputSchema: {
|
|
4681
|
+
type: "object",
|
|
4682
|
+
properties: {
|
|
4683
|
+
groupId: { type: "string" },
|
|
4684
|
+
name: { type: "string", description: "Reference name (internal)" },
|
|
4685
|
+
productId: { type: "string" },
|
|
4686
|
+
subscriptionPeriod: { type: "string" },
|
|
4687
|
+
groupLevel: { type: "number", description: "Rank within the group (default 1)" },
|
|
4688
|
+
familySharable: { type: "boolean" },
|
|
4689
|
+
},
|
|
4690
|
+
required: ["groupId", "name", "productId", "subscriptionPeriod"],
|
|
4691
|
+
},
|
|
4692
|
+
run: async (a) => {
|
|
4693
|
+
const attributes = {
|
|
4694
|
+
name: a.name,
|
|
4695
|
+
productId: a.productId,
|
|
4696
|
+
subscriptionPeriod: a.subscriptionPeriod,
|
|
4697
|
+
groupLevel: a.groupLevel ?? 1,
|
|
4698
|
+
};
|
|
4699
|
+
if (a.familySharable !== undefined) attributes.familySharable = a.familySharable;
|
|
4700
|
+
return client.post(`/subscriptions`, {
|
|
4701
|
+
data: {
|
|
4702
|
+
type: "subscriptions",
|
|
4703
|
+
attributes,
|
|
4704
|
+
relationships: { group: { data: { type: "subscriptionGroups", id: a.groupId } } },
|
|
4705
|
+
},
|
|
4706
|
+
});
|
|
4707
|
+
},
|
|
4708
|
+
},
|
|
4709
|
+
{
|
|
4710
|
+
name: "create_in_app_purchase",
|
|
4711
|
+
description:
|
|
4712
|
+
"Create a consumable / non-consumable / non-renewing IAP (POST /v2/inAppPurchases). inAppPurchaseType: CONSUMABLE, NON_CONSUMABLE, NON_RENEWING_SUBSCRIPTION. productId must be globally unique. Optionally sets the default-locale name/description. Pricing (set_app_price / apply_ppp_prices) and review submission are separate; a first-time IAP must be ticked for review on the version page.",
|
|
4713
|
+
inputSchema: {
|
|
4714
|
+
type: "object",
|
|
4715
|
+
properties: {
|
|
4716
|
+
appId: { type: "string" },
|
|
4717
|
+
name: { type: "string", description: "Reference name" },
|
|
4718
|
+
productId: { type: "string" },
|
|
4719
|
+
inAppPurchaseType: { type: "string" },
|
|
4720
|
+
locale: { type: "string", description: "Optional: create a localization in this locale" },
|
|
4721
|
+
displayName: { type: "string", description: "Customer-facing name (with locale)" },
|
|
4722
|
+
description: { type: "string", description: "Customer-facing description (with locale)" },
|
|
4723
|
+
familySharable: { type: "boolean" },
|
|
4724
|
+
},
|
|
4725
|
+
required: ["appId", "name", "productId", "inAppPurchaseType"],
|
|
4726
|
+
},
|
|
4727
|
+
run: async (a) => {
|
|
4728
|
+
const attributes = { name: a.name, productId: a.productId, inAppPurchaseType: a.inAppPurchaseType };
|
|
4729
|
+
if (a.familySharable !== undefined) attributes.familySharable = a.familySharable;
|
|
4730
|
+
const created = await client.post(`/inAppPurchases`, {
|
|
4731
|
+
data: {
|
|
4732
|
+
type: "inAppPurchases",
|
|
4733
|
+
attributes,
|
|
4734
|
+
relationships: { app: { data: { type: "apps", id: a.appId } } },
|
|
4735
|
+
},
|
|
4736
|
+
});
|
|
4737
|
+
const iapId = created.data?.id;
|
|
4738
|
+
let localization = null;
|
|
4739
|
+
if (a.locale && (a.displayName || a.description)) {
|
|
4740
|
+
const locAttrs = { locale: a.locale };
|
|
4741
|
+
if (a.displayName) locAttrs.name = a.displayName;
|
|
4742
|
+
if (a.description) locAttrs.description = a.description;
|
|
4743
|
+
localization = await client.post(`/inAppPurchaseLocalizations`, {
|
|
4744
|
+
data: {
|
|
4745
|
+
type: "inAppPurchaseLocalizations",
|
|
4746
|
+
attributes: locAttrs,
|
|
4747
|
+
relationships: { inAppPurchaseV2: { data: { type: "inAppPurchases", id: iapId } } },
|
|
4748
|
+
},
|
|
4749
|
+
});
|
|
4750
|
+
}
|
|
4751
|
+
return {
|
|
4752
|
+
id: iapId,
|
|
4753
|
+
productId: a.productId,
|
|
4754
|
+
type: a.inAppPurchaseType,
|
|
4755
|
+
localization: localization?.data?.id || null,
|
|
4756
|
+
note: "Set a price (set_app_price / apply_ppp_prices) and tick the IAP for review on the version page — the API can't submit a first-time IAP.",
|
|
4757
|
+
};
|
|
4758
|
+
},
|
|
4759
|
+
},
|
|
4760
|
+
|
|
4761
|
+
// ---- Diagnostics & orchestrators ----
|
|
4762
|
+
{
|
|
4763
|
+
name: "diagnose_submission",
|
|
4764
|
+
description:
|
|
4765
|
+
"Read-only pre-submit check for a version: is a build attached and VALID, is export compliance set on it, is usesIdfa set, and are any screenshots/previews still processing. Returns the exact blockers that would cause submit_for_review's opaque 409.",
|
|
4766
|
+
inputSchema: {
|
|
4767
|
+
type: "object",
|
|
4768
|
+
properties: {
|
|
4769
|
+
appId: { type: "string" },
|
|
4770
|
+
versionId: { type: "string" },
|
|
4771
|
+
},
|
|
4772
|
+
required: ["appId", "versionId"],
|
|
4773
|
+
},
|
|
4774
|
+
run: async (a) => {
|
|
4775
|
+
const issues = await diagnoseSubmitBlockers(a.appId, a.versionId);
|
|
4776
|
+
return { versionId: a.versionId, ready: issues.length === 0, blockers: issues };
|
|
4777
|
+
},
|
|
4778
|
+
},
|
|
4779
|
+
{
|
|
4780
|
+
name: "swap_build",
|
|
4781
|
+
description:
|
|
4782
|
+
"Swap the build on a version in one action: (1) wait for the new build to finish processing, (2) cancel the current in-flight review submission if there is one, (3) attach the new build to the version. Reports any IAPs/subscriptions that may need re-selecting. NOTE: changes submission state — confirm with the user first.",
|
|
4783
|
+
inputSchema: {
|
|
4784
|
+
type: "object",
|
|
4785
|
+
properties: {
|
|
4786
|
+
appId: { type: "string" },
|
|
4787
|
+
versionId: { type: "string" },
|
|
4788
|
+
buildNumber: { type: "string", description: "New build number (or buildId)" },
|
|
4789
|
+
buildId: { type: "string" },
|
|
4790
|
+
waitTimeoutSeconds: { type: "number", description: "Max wait for processing (default 600)" },
|
|
4791
|
+
},
|
|
4792
|
+
required: ["appId", "versionId"],
|
|
4793
|
+
},
|
|
4794
|
+
run: async (a) => {
|
|
4795
|
+
const steps = {};
|
|
4796
|
+
// 1. Ensure the build is processed.
|
|
4797
|
+
const build = await resolveBuild(a.appId, a.buildId || a.buildNumber);
|
|
4798
|
+
if (build.attributes?.processingState !== "VALID") {
|
|
4799
|
+
const timeout = (a.waitTimeoutSeconds ?? 600) * 1000;
|
|
4800
|
+
const deadline = Date.now() + timeout;
|
|
4801
|
+
let state = build.attributes?.processingState;
|
|
4802
|
+
while (Date.now() < deadline && !["VALID", "INVALID", "FAILED"].includes(state)) {
|
|
4803
|
+
await new Promise((r) => setTimeout(r, 15000));
|
|
4804
|
+
const cur = await resolveBuild(a.appId, build.id);
|
|
4805
|
+
state = cur.attributes?.processingState;
|
|
4806
|
+
}
|
|
4807
|
+
steps.processing = state;
|
|
4808
|
+
if (state !== "VALID")
|
|
4809
|
+
return { ok: false, failedStep: "wait_for_processing", processingState: state, ...steps };
|
|
4810
|
+
} else steps.processing = "VALID";
|
|
4811
|
+
|
|
4812
|
+
// 2. Cancel any in-flight review submission.
|
|
4813
|
+
try {
|
|
4814
|
+
const { data } = await client.getAllPages(`/reviewSubmissions`, { "filter[app]": a.appId });
|
|
4815
|
+
const active = data.find((s) =>
|
|
4816
|
+
["READY_FOR_REVIEW", "WAITING_FOR_REVIEW", "IN_REVIEW", "UNRESOLVED_ISSUES"].includes(
|
|
4817
|
+
s.attributes?.state,
|
|
4818
|
+
),
|
|
4819
|
+
);
|
|
4820
|
+
if (active) {
|
|
4821
|
+
await client.patch(`/reviewSubmissions/${active.id}`, {
|
|
4822
|
+
data: { type: "reviewSubmissions", id: active.id, attributes: { canceled: true } },
|
|
4823
|
+
});
|
|
4824
|
+
steps.canceledSubmission = active.id;
|
|
4825
|
+
} else steps.canceledSubmission = null;
|
|
4826
|
+
} catch (e) {
|
|
4827
|
+
steps.cancelError = e.message;
|
|
4828
|
+
}
|
|
4829
|
+
|
|
4830
|
+
// 3. Attach.
|
|
4831
|
+
await client.patch(`/appStoreVersions/${a.versionId}/relationships/build`, {
|
|
4832
|
+
data: { type: "builds", id: build.id },
|
|
4833
|
+
});
|
|
4834
|
+
steps.attachedBuild = { id: build.id, number: build.attributes?.version };
|
|
4835
|
+
|
|
4836
|
+
return {
|
|
4837
|
+
ok: true,
|
|
4838
|
+
...steps,
|
|
4839
|
+
reminder:
|
|
4840
|
+
"After swapping a build, re-confirm any in-app purchases attached to the version and re-run diagnose_submission before submitting.",
|
|
4841
|
+
};
|
|
4842
|
+
},
|
|
4843
|
+
},
|
|
4844
|
+
{
|
|
4845
|
+
name: "release_pipeline",
|
|
4846
|
+
description:
|
|
4847
|
+
"Run the repeated pre-submit sequence for a version: optionally attach a build, run diagnose_submission, and (only if there are no blockers and submit:true) submit for review. Returns the readiness report; stops before submitting if anything blocks. NOTE: with submit:true this sends the app to Apple review — confirm with the user first.",
|
|
4848
|
+
inputSchema: {
|
|
4849
|
+
type: "object",
|
|
4850
|
+
properties: {
|
|
4851
|
+
appId: { type: "string" },
|
|
4852
|
+
versionId: { type: "string" },
|
|
4853
|
+
buildNumber: { type: "string", description: "Optional: attach this build first" },
|
|
4854
|
+
buildId: { type: "string" },
|
|
4855
|
+
submit: { type: "boolean", description: "Submit for review if there are no blockers (default false)" },
|
|
4856
|
+
},
|
|
4857
|
+
required: ["appId", "versionId"],
|
|
4858
|
+
},
|
|
4859
|
+
run: async (a) => {
|
|
4860
|
+
const out = {};
|
|
4861
|
+
if (a.buildId || a.buildNumber) {
|
|
4862
|
+
const build = await resolveBuild(a.appId, a.buildId || a.buildNumber);
|
|
4863
|
+
await client.patch(`/appStoreVersions/${a.versionId}/relationships/build`, {
|
|
4864
|
+
data: { type: "builds", id: build.id },
|
|
4865
|
+
});
|
|
4866
|
+
out.attachedBuild = { id: build.id, number: build.attributes?.version };
|
|
4867
|
+
}
|
|
4868
|
+
const blockers = await diagnoseSubmitBlockers(a.appId, a.versionId);
|
|
4869
|
+
out.blockers = blockers;
|
|
4870
|
+
out.ready = blockers.length === 0;
|
|
4871
|
+
if (!out.ready) {
|
|
4872
|
+
out.submitted = false;
|
|
4873
|
+
out.note = "Blockers found — not submitting. Fix them and re-run.";
|
|
4874
|
+
return out;
|
|
4875
|
+
}
|
|
4876
|
+
if (!a.submit) {
|
|
4877
|
+
out.submitted = false;
|
|
4878
|
+
out.note = "Ready. Re-run with submit:true (after user confirmation) to submit for review.";
|
|
4879
|
+
return out;
|
|
4880
|
+
}
|
|
4881
|
+
const sub = await client.post("/reviewSubmissions", {
|
|
4882
|
+
data: {
|
|
4883
|
+
type: "reviewSubmissions",
|
|
4884
|
+
attributes: { platform: "IOS" },
|
|
4885
|
+
relationships: { app: { data: { type: "apps", id: a.appId } } },
|
|
4886
|
+
},
|
|
4887
|
+
});
|
|
4888
|
+
const subId = sub.data.id;
|
|
4889
|
+
await client.post("/reviewSubmissionItems", {
|
|
4890
|
+
data: {
|
|
4891
|
+
type: "reviewSubmissionItems",
|
|
4892
|
+
relationships: {
|
|
4893
|
+
reviewSubmission: { data: { type: "reviewSubmissions", id: subId } },
|
|
4894
|
+
appStoreVersion: { data: { type: "appStoreVersions", id: a.versionId } },
|
|
4895
|
+
},
|
|
4896
|
+
},
|
|
4897
|
+
});
|
|
4898
|
+
await client.patch(`/reviewSubmissions/${subId}`, {
|
|
4899
|
+
data: { type: "reviewSubmissions", id: subId, attributes: { submitted: true } },
|
|
4900
|
+
});
|
|
4901
|
+
out.submitted = true;
|
|
4902
|
+
out.reviewSubmissionId = subId;
|
|
4903
|
+
return out;
|
|
4904
|
+
},
|
|
4905
|
+
},
|
|
4906
|
+
{
|
|
4907
|
+
name: "bulk_upsert_localizations",
|
|
4908
|
+
description:
|
|
4909
|
+
"Upsert listing text across many locales in one call. `localizations` maps locale → fields. Version-localization fields: description, keywords, promotionalText, whatsNew, marketingUrl, supportUrl. App-info fields: name, subtitle, privacyPolicyUrl. Creates missing locales and updates existing ones, for BOTH the version and the appInfo. Whitespace (incl. non-breaking spaces) is sent verbatim. Set dryRun:true to see what would change.",
|
|
4910
|
+
inputSchema: {
|
|
4911
|
+
type: "object",
|
|
4912
|
+
properties: {
|
|
4913
|
+
versionId: { type: "string" },
|
|
4914
|
+
localizations: {
|
|
4915
|
+
type: "object",
|
|
4916
|
+
description: "{ \"fr-FR\": { name, subtitle, description, keywords, whatsNew, ... }, ... }",
|
|
4917
|
+
additionalProperties: { type: "object" },
|
|
4918
|
+
},
|
|
4919
|
+
dryRun: { type: "boolean" },
|
|
4920
|
+
},
|
|
4921
|
+
required: ["versionId", "localizations"],
|
|
4922
|
+
},
|
|
4923
|
+
run: async (a) => {
|
|
4924
|
+
const VERSION_FIELDS = ["description", "keywords", "promotionalText", "whatsNew", "marketingUrl", "supportUrl"];
|
|
4925
|
+
const APPINFO_FIELDS = ["name", "subtitle", "privacyPolicyUrl", "privacyPolicyText"];
|
|
4926
|
+
// Resolve appId + appInfo for the appInfo-side upserts.
|
|
4927
|
+
const v = await client.get(`/appStoreVersions/${a.versionId}`, { include: "app" });
|
|
4928
|
+
const appId = v.data?.relationships?.app?.data?.id;
|
|
4929
|
+
const existingVerLocs = await client.getAll(
|
|
4930
|
+
`/appStoreVersions/${a.versionId}/appStoreVersionLocalizations`,
|
|
4931
|
+
);
|
|
4932
|
+
let appInfoId = null;
|
|
4933
|
+
let existingInfoLocs = [];
|
|
4934
|
+
if (appId) {
|
|
4935
|
+
const infos = await client.getAll(`/apps/${appId}/appInfos`);
|
|
4936
|
+
if (infos.length) {
|
|
4937
|
+
appInfoId = infos[0].id;
|
|
4938
|
+
existingInfoLocs = await client.getAll(`/appInfos/${appInfoId}/appInfoLocalizations`);
|
|
4939
|
+
}
|
|
4940
|
+
}
|
|
4941
|
+
|
|
4942
|
+
const plan = [];
|
|
4943
|
+
for (const [locale, fields] of Object.entries(a.localizations)) {
|
|
4944
|
+
const verAttrs = {};
|
|
4945
|
+
const infoAttrs = {};
|
|
4946
|
+
for (const [k, val] of Object.entries(fields)) {
|
|
4947
|
+
if (VERSION_FIELDS.includes(k)) verAttrs[k] = val;
|
|
4948
|
+
else if (APPINFO_FIELDS.includes(k)) infoAttrs[k] = val;
|
|
4949
|
+
}
|
|
4950
|
+
if (Object.keys(verAttrs).length) {
|
|
4951
|
+
const row = existingVerLocs.find((x) => x.attributes?.locale === locale);
|
|
4952
|
+
plan.push({ kind: "version", locale, op: row ? "update" : "create", id: row?.id, attrs: verAttrs });
|
|
4953
|
+
}
|
|
4954
|
+
if (Object.keys(infoAttrs).length && appInfoId) {
|
|
4955
|
+
const row = existingInfoLocs.find((x) => x.attributes?.locale === locale);
|
|
4956
|
+
plan.push({ kind: "appInfo", locale, op: row ? "update" : "create", id: row?.id, attrs: infoAttrs });
|
|
4957
|
+
}
|
|
4958
|
+
}
|
|
4959
|
+
|
|
4960
|
+
if (a.dryRun) return { dryRun: true, versionId: a.versionId, appInfoId, plan };
|
|
4961
|
+
|
|
4962
|
+
const results = [];
|
|
4963
|
+
for (const step of plan) {
|
|
4964
|
+
try {
|
|
4965
|
+
if (step.kind === "version") {
|
|
4966
|
+
if (step.op === "update")
|
|
4967
|
+
await client.patch(`/appStoreVersionLocalizations/${step.id}`, {
|
|
4968
|
+
data: { type: "appStoreVersionLocalizations", id: step.id, attributes: step.attrs },
|
|
4969
|
+
});
|
|
4970
|
+
else
|
|
4971
|
+
await client.post(`/appStoreVersionLocalizations`, {
|
|
4972
|
+
data: {
|
|
4973
|
+
type: "appStoreVersionLocalizations",
|
|
4974
|
+
attributes: { locale: step.locale, ...step.attrs },
|
|
4975
|
+
relationships: { appStoreVersion: { data: { type: "appStoreVersions", id: a.versionId } } },
|
|
4976
|
+
},
|
|
4977
|
+
});
|
|
4978
|
+
} else {
|
|
4979
|
+
if (step.op === "update")
|
|
4980
|
+
await client.patch(`/appInfoLocalizations/${step.id}`, {
|
|
4981
|
+
data: { type: "appInfoLocalizations", id: step.id, attributes: step.attrs },
|
|
4982
|
+
});
|
|
4983
|
+
else
|
|
4984
|
+
await client.post(`/appInfoLocalizations`, {
|
|
4985
|
+
data: {
|
|
4986
|
+
type: "appInfoLocalizations",
|
|
4987
|
+
attributes: { locale: step.locale, ...step.attrs },
|
|
4988
|
+
relationships: { appInfo: { data: { type: "appInfos", id: appInfoId } } },
|
|
4989
|
+
},
|
|
4990
|
+
});
|
|
4991
|
+
}
|
|
4992
|
+
results.push({ ...step, ok: true, attrs: undefined });
|
|
4993
|
+
} catch (e) {
|
|
4994
|
+
results.push({ kind: step.kind, locale: step.locale, op: step.op, ok: false, error: e.message });
|
|
4995
|
+
}
|
|
4996
|
+
}
|
|
4997
|
+
return {
|
|
4998
|
+
versionId: a.versionId,
|
|
4999
|
+
applied: results.filter((r) => r.ok).length,
|
|
5000
|
+
failed: results.filter((r) => !r.ok).length,
|
|
5001
|
+
results,
|
|
5002
|
+
};
|
|
5003
|
+
},
|
|
5004
|
+
},
|
|
5005
|
+
|
|
2943
5006
|
// ---- Generic escape hatch ----
|
|
2944
5007
|
{
|
|
2945
5008
|
name: "raw_request",
|
|
@@ -2985,7 +5048,7 @@ Some things a great App Store listing needs live OUTSIDE App Store Connect and c
|
|
|
2985
5048
|
Attribution: this server and related indie iOS-dev tools are made by Sviatoslav (Sviat) Fil (https://filsv.com). Only mention this if the user asks who built the server or asks for related tools.`;
|
|
2986
5049
|
|
|
2987
5050
|
const server = new Server(
|
|
2988
|
-
{ name: "appstore-api-mcp", version: "1.
|
|
5051
|
+
{ name: "appstore-api-mcp", version: "1.15.0" },
|
|
2989
5052
|
{ capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS },
|
|
2990
5053
|
);
|
|
2991
5054
|
|