vydanne 0.7.0 → 0.8.1

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/README.md CHANGED
@@ -201,7 +201,7 @@ DRY RUN — 40 store write(s) withheld. Re-run with --apply to perform them.
201
201
  ```
202
202
 
203
203
  The commands this applies to are marked `✎` in the usage text (`vydanne` with no arguments): `prepare`, `push`, `fill`, `previews`,
204
- `age-rating`, `review-contact`, `accessibility`, `prerelease`. Everything else only reads, and ignores
204
+ `appinfo`, `age-rating`, `review-contact`, `accessibility`, `prerelease`. Everything else only reads, and ignores
205
205
  the flag.
206
206
 
207
207
  Two details worth knowing:
@@ -232,6 +232,7 @@ Two details worth knowing:
232
232
  | `previews` | Uploads App Preview videos. |
233
233
  | `inspect` | Shows the app's current state in the store. Read-only. |
234
234
  | `locales` | Lists your languages and Apple's code for each — and warns about any language the App Store doesn't offer. |
235
+ | `appinfo` | Sets the App Store **category** and the **content-rights** answer — the two app-level facts that block *Add for Review* and belong to no single release. Declared as `categories` (Apple's ids: `GAMES`, `GAMES_PUZZLE` — never display names) and `contentRights: false` for an app that shows no third-party content. Left undeclared, `contentRights` is not written at all, so an answer already given in App Store Connect is never overwritten by a default nobody chose. |
235
236
  | `age-rating` | Sets the age rating. `rating: "4+"` needs nothing else; any higher rating is described feature-by-feature in `ageRating` and Apple computes the band from it. |
236
237
  | `review-contact` | Fills in the App Review contact details (who Apple calls if there's a problem). |
237
238
  | `accessibility` | Saves Accessibility Nutrition Labels from the `accessibility` block in your config. Stays a draft until your app is live. Refuses to run if you have not declared one — see below. |
@@ -243,7 +244,7 @@ Two details worth knowing:
243
244
 
244
245
  For **Google Play**, add `--store google` to `inspect`, `diff`, `preflight`, `fill`, or `prerelease`.
245
246
 
246
- `prepare`, `push`, `fill`, `previews`, `age-rating`, `review-contact`, `accessibility` and `prerelease`
247
+ `prepare`, `push`, `fill`, `previews`, `appinfo`, `age-rating`, `review-contact`, `accessibility` and `prerelease`
247
248
  change the store, so they need [`--apply`](#--apply-or-nothing-happens); without it they report and exit.
248
249
 
249
250
  ## The release pipeline — the order matters
package/SKILL.md CHANGED
@@ -210,7 +210,7 @@ who it's for → honest close. Keep it scannable; lead each bullet with the payo
210
210
  ## B. Commands (push it)
211
211
 
212
212
  `fill` (metadata + screenshots, native PATCH/chunked upload — works even at READY_FOR_REVIEW) ·
213
- `previews` (App Preview videos) · `age-rating` · `review-contact` · `accessibility` (draft; publish once
213
+ `previews` (App Preview videos) · `appinfo` (category + content rights) · `age-rating` · `review-contact` · `accessibility` (draft; publish once
214
214
  live) · `privacy` (prints answers for the UI — the API can't reach Apple's iris host) · `iap` (validate +
215
215
  RGB flatten) · `compliance` (US self-classification PDF) · `bridge` (zdymak's output → the folders
216
216
  `fill` reads) · `diff` (what differs vs live, text AND media by checksum) · `preflight`
@@ -303,7 +303,7 @@ The AAB binary and the (YouTube-URL) promo video stay outside vydanne.
303
303
  ## `--apply` — writes are opt-in
304
304
 
305
305
  **Every store-mutating command is a DRY RUN without `--apply`**: `prepare` · `push` · `fill` ·
306
- `previews` · `age-rating` · `review-contact` · `accessibility` · `prerelease` (marked `✎` in
306
+ `previews` · `appinfo` · `age-rating` · `review-contact` · `accessibility` · `prerelease` (marked `✎` in
307
307
  the usage text, printed by `vydanne` with no arguments). They read the store, print each write they would make, and send nothing. Read-only
308
308
  commands ignore the flag.
309
309
 
@@ -343,3 +343,21 @@ promo) · char limits (name/subtitle 30, keywords 100, promo 170; IAP name 30 /
343
343
  short 80 / full 4000) · **Play uses its OWN locale codes** · Play images push only when the local file
344
344
  exists, so a missing set never wipes the live one · Apple requires `name` when *creating* an app-info
345
345
  localization (409 otherwise).
346
+
347
+ ## The blockers that only appear on the Add for Review screen
348
+
349
+ Apple checks these last, so a release can be green everywhere — metadata filled, screenshots up,
350
+ build attached, `preflight` clean — and still stop dead with a list nobody can act on from a
351
+ terminal. Four of the five are now vydanne's; the fifth is not reachable by any API.
352
+
353
+ | blocker | who sets it |
354
+ |---|---|
355
+ | Privacy Policy URL | `fill` — `privacy_url.txt` per locale (an `appInfoLocalizations` field, so **every** locale needs it) |
356
+ | Copyright | `prepare` — `<metadataDir>/copyright.txt`, on create **and** on reuse |
357
+ | Primary category | `appinfo` — `categories` |
358
+ | Content Rights | `appinfo` — `contentRights` |
359
+ | **Price tier** | **App Store Connect UI.** Pricing is a separate agreement-bound surface; vydanne does not touch money. |
360
+
361
+ `preflight` will NOT catch these. It verifies submission-completeness of the things it writes; a
362
+ category it never sets is not a gap it knows to look for. Run `appinfo` once per app and the list
363
+ shortens to the price.
package/bin/vydanne.mjs CHANGED
@@ -128,6 +128,10 @@ usage: vydanne <command> [--apply] [--config vydanne.config.mjs]
128
128
  skipped); every skip is reported again at the end, so green still means green.
129
129
  Ends at a green preflight — Add to Review + Submit stay yours, in the web UI.
130
130
  ✎ fill metadata + screenshots + previews (native; iOS & macOS separate)
131
+ ✎ appinfo App Store category + the content-rights answer — the two app-level facts that
132
+ block Add for Review and belong to no single release. 'categories' takes Apple's
133
+ ids (GAMES, GAMES_PUZZLE — not display names); 'contentRights: false' for an app
134
+ showing no third-party content. Set once; re-running is a no-op.
131
135
  ✎ age-rating set the age rating (AppInfo declaration)
132
136
  ✎ review-contact App Review contact from the gitignored files
133
137
  ✎ accessibility Accessibility Nutrition Labels (draft; VYDANNE_A11Y_PUBLISH=1 to publish once live)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vydanne",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "description": "App Store Connect + Google Play submission prep — the companion to zdymak (media). Native Node (no fastlane/Ruby/Python): localized listings, screenshot/preview/icon upload, ratings, review contact, accessibility & privacy labels, IAP, export docs, build upload to TestFlight / a Play closed track, a diff of local-vs-store, and a preflight verifier that encodes the store gotchas. Never submits for review. iOS/macOS via the ASC REST API; Android via the Play Developer Edits API (--store google).",
5
5
  "keywords": [
6
6
  "app-store-connect",
@@ -52,7 +52,8 @@
52
52
  "check:docs": "node scripts/check-docs.mjs",
53
53
  "check:types": "tsc --noEmit -p tsconfig.json && node scripts/check-types.mjs",
54
54
  "check:config": "node scripts/check-config.mjs",
55
- "prepublishOnly": "npm run check:docs && npm run check:types && npm run check:config",
55
+ "verify": "npm run check:docs && npm run check:types && npm run check:config",
56
+ "prepublishOnly": "npm run verify",
56
57
  "release:patch": "node scripts/release.mjs patch",
57
58
  "release:minor": "node scripts/release.mjs minor",
58
59
  "release:major": "node scripts/release.mjs major"
@@ -0,0 +1,124 @@
1
+ import { green, yellow, red } from "../util.mjs";
2
+
3
+ /**
4
+ * The two app-level facts that block **Add for Review** and nothing else in vydanne could set.
5
+ *
6
+ * You must select a primary category for your app.
7
+ * You must set up Content Rights Information in App Information.
8
+ *
9
+ * Both are one API call each and were simply never wired, so a release that was green everywhere —
10
+ * metadata filled, screenshots uploaded, build attached, preflight clean — still stopped dead at the
11
+ * last screen with two errors nobody could act on from a terminal. That is the worst place to
12
+ * discover missing automation: after the work, in a web form, with no record of what the right
13
+ * answer was last time.
14
+ *
15
+ * Deliberately its OWN command rather than folded into `prepare`. `prepare` is about a version; these
16
+ * are about the app, and they outlive every version — setting them once is normal and re-running is a
17
+ * no-op. Keeping them separate also means a failure here names itself instead of failing a step whose
18
+ * name says "version".
19
+ */
20
+
21
+ /**
22
+ * Apple's category vocabulary is a fixed set of ids, and `GAMES` is the only one with subcategories.
23
+ * Listed rather than free-typed because a wrong id returns a 409 that names neither the field nor the
24
+ * legal values, and the guess someone makes from that ("Puzzle", "games") is wrong twice over — the
25
+ * ids are upper snake case and game subcategories are prefixed.
26
+ */
27
+ const CATEGORIES = new Set([
28
+ "BOOKS", "BUSINESS", "DEVELOPER_TOOLS", "EDUCATION", "ENTERTAINMENT", "FINANCE", "FOOD_AND_DRINK",
29
+ "GAMES", "GRAPHICS_AND_DESIGN", "HEALTH_AND_FITNESS", "LIFESTYLE", "MAGAZINES_AND_NEWSPAPERS",
30
+ "MEDICAL", "MUSIC", "NAVIGATION", "NEWS", "PHOTO_AND_VIDEO", "PRODUCTIVITY", "REFERENCE",
31
+ "SHOPPING", "SOCIAL_NETWORKING", "SPORTS", "TRAVEL", "UTILITIES", "WEATHER",
32
+ ]);
33
+
34
+ const GAME_SUBCATEGORIES = new Set([
35
+ "GAMES_ACTION", "GAMES_ADVENTURE", "GAMES_BOARD", "GAMES_CARD", "GAMES_CASINO", "GAMES_CASUAL",
36
+ "GAMES_FAMILY", "GAMES_MUSIC", "GAMES_PUZZLE", "GAMES_RACING", "GAMES_ROLE_PLAYING",
37
+ "GAMES_SIMULATION", "GAMES_SPORTS", "GAMES_STRATEGY", "GAMES_TRIVIA", "GAMES_WORD",
38
+ ]);
39
+
40
+ /** Apple's own two values, stated as a question so a config cannot get the polarity backwards. */
41
+ const RIGHTS = {
42
+ true: "USES_THIRD_PARTY_CONTENT",
43
+ false: "DOES_NOT_USE_THIRD_PARTY_CONTENT",
44
+ };
45
+
46
+ function validate(config) {
47
+ const c = config.categories;
48
+ if (!c) return { problem: "appinfo: no `categories` in the config — set at least { primary: 'GAMES' }." };
49
+ const bad = [];
50
+ if (!CATEGORIES.has(c.primary)) bad.push(`primary '${c.primary}' is not an Apple category id (e.g. GAMES, PUZZLE is a SUBcategory)`);
51
+ if (c.secondary && !CATEGORIES.has(c.secondary)) bad.push(`secondary '${c.secondary}' is not an Apple category id`);
52
+ for (const k of ["primarySubcategoryOne", "primarySubcategoryTwo", "secondarySubcategoryOne", "secondarySubcategoryTwo"]) {
53
+ if (c[k] && !GAME_SUBCATEGORIES.has(c[k])) bad.push(`${k} '${c[k]}' is not a game subcategory id (they are GAMES_*)`);
54
+ }
55
+ // Apple allows subcategories only under GAMES; sending them otherwise is a 409 that reads as a
56
+ // generic relationship error.
57
+ if (c.primarySubcategoryOne && c.primary !== "GAMES") bad.push("subcategories are only valid when primary is GAMES");
58
+ if (config.contentRights !== undefined && typeof config.contentRights !== "boolean") {
59
+ bad.push("contentRights must be true (uses third-party content) or false (does not)");
60
+ }
61
+ return bad.length ? { problem: `appinfo: ${bad.join("; ")}` } : {};
62
+ }
63
+
64
+ const rel = (id) => (id ? { data: { type: "appCategories", id } } : { data: null });
65
+
66
+ export async function run(config, client) {
67
+ const { problem } = validate(config);
68
+ if (problem) { console.error(red(problem)); return false; }
69
+
70
+ await client.findApp(config.bundleId);
71
+ // Same refusal as age-rating, and for the same reason: the LIVE app info's category is not ours to
72
+ // aim a write at, and appInfo() returning the live record instead of null once made that write look
73
+ // like it had worked.
74
+ const info = await client.appInfo();
75
+ if (!info) {
76
+ console.error(red("appinfo: no editable app info — refusing to write to the live record."));
77
+ console.error(" `vydanne prepare --apply` starts the next version, which makes app info editable again.");
78
+ return false;
79
+ }
80
+
81
+ const c = config.categories;
82
+ const relationships = {
83
+ primaryCategory: rel(c.primary),
84
+ primarySubcategoryOne: rel(c.primarySubcategoryOne),
85
+ primarySubcategoryTwo: rel(c.primarySubcategoryTwo),
86
+ secondaryCategory: rel(c.secondary),
87
+ secondarySubcategoryOne: rel(c.secondarySubcategoryOne),
88
+ secondarySubcategoryTwo: rel(c.secondarySubcategoryTwo),
89
+ };
90
+ const shown = [c.primary, c.primarySubcategoryOne, c.primarySubcategoryTwo, c.secondary]
91
+ .filter(Boolean).join(" · ");
92
+ console.log(` categories: ${shown}`);
93
+
94
+ const r = await client.patch(`/v1/appInfos/${info.id}`, {
95
+ data: { type: "appInfos", id: info.id, relationships },
96
+ });
97
+ if (r.status >= 300) {
98
+ console.error(red(`appinfo: categories ${r.status}`));
99
+ for (const e of r.json?.errors || []) console.error(` ${e.title}: ${e.detail}`);
100
+ return false;
101
+ }
102
+ console.log(client.dryRun ? yellow(` WOULD set categories`) : green(` categories set`));
103
+
104
+ // Content rights is on the APP, not the app info — a different resource, which is most of why it
105
+ // was missed. Skipped entirely when undeclared, so an app that has already answered in the UI is
106
+ // not overwritten by a default nobody chose.
107
+ if (config.contentRights === undefined) {
108
+ console.log(yellow(" contentRights not declared — Add for Review needs it; set `contentRights: false` if the app uses no third-party content."));
109
+ return true;
110
+ }
111
+ const declaration = RIGHTS[String(config.contentRights)];
112
+ const a = await client.patch(`/v1/apps/${client.appId}`, {
113
+ data: { type: "apps", id: client.appId, attributes: { contentRightsDeclaration: declaration } },
114
+ });
115
+ if (a.status >= 300) {
116
+ console.error(red(`appinfo: content rights ${a.status}`));
117
+ for (const e of a.json?.errors || []) console.error(` ${e.title}: ${e.detail}`);
118
+ return false;
119
+ }
120
+ console.log(client.dryRun
121
+ ? yellow(` WOULD set content rights -> ${declaration}`)
122
+ : green(` content rights -> ${declaration}`));
123
+ return true;
124
+ }
@@ -10,7 +10,12 @@ const VERSION_FIELDS = [
10
10
  ["description", "description", true], ["keywords", "keywords", false], ["promotionalText", "promotional_text", false],
11
11
  ["whatsNew", "release_notes", true], ["marketingUrl", "marketing_url", false], ["supportUrl", "support_url", false],
12
12
  ];
13
- const INFO_FIELDS = [["name", "name", false], ["subtitle", "subtitle", false]];
13
+ // Must list everything `fill`'s INFO_TXT writes, or `diff` reports "in sync" over a field it never
14
+ // looked at — which is not a harmless gap: `fill` PATCHes unconditionally (patchAttrs never compares),
15
+ // so `diff` is the ONLY thing that can say whether a value actually reached Apple. When
16
+ // privacyPolicyUrl was added to fill and not here, one command claimed 19 pending writes while the
17
+ // other claimed sync, and neither was evidence of anything.
18
+ const INFO_FIELDS = [["name", "name", false], ["subtitle", "subtitle", false], ["privacyPolicyUrl", "privacy_url", false]];
14
19
 
15
20
  const norm = (s) => (s == null ? null : String(s).replace(/\r/g, "").replace(/\n+$/, "").trim());
16
21
  const short = (s, n = 24) => { s = String(s).replace(/\n/g, " "); return s.length > n ? s.slice(0, n) + "…" : s; };
@@ -6,9 +6,15 @@ import { uploadAsset } from "../upload.mjs";
6
6
  import { reportCrossStore } from "../crossStore.mjs";
7
7
  import { deviceMap, screenshotBase, IMAGE_FILE } from "../screenshots.mjs";
8
8
 
9
- // Version-localization fields (attr -> metadata filename) and AppInfo fields (name/subtitle, shared).
9
+ // Version-localization fields (attr -> metadata filename) and AppInfo fields (shared across releases).
10
10
  const VERSION_TXT = { description: "description", keywords: "keywords", promotionalText: "promotional_text", whatsNew: "release_notes", marketingUrl: "marketing_url", supportUrl: "support_url" };
11
- const INFO_TXT = { name: "name", subtitle: "subtitle" };
11
+ // `privacyPolicyUrl` belongs here rather than on the version because it is an APP-level fact, not a
12
+ // per-release one. Apple blocks Add for Review without it ("You must enter a Privacy Policy URL in
13
+ // App Privacy") — a wall you hit at the very end, once everything else is green, and one nothing in
14
+ // vydanne could clear because this map stopped at name/subtitle. It is per-LOCALE all the same, so a
15
+ // game with a single policy page writes the same line into each locale's `privacy_url.txt`; the
16
+ // filename follows fastlane's own convention, so an existing metadata tree needs no rearranging.
17
+ const INFO_TXT = { name: "name", subtitle: "subtitle", privacyPolicyUrl: "privacy_url" };
12
18
 
13
19
  /**
14
20
  * PATCH a localization, surviving attributes Apple refuses to edit RIGHT NOW.
@@ -44,7 +44,12 @@ const NEXT_STEPS = [
44
44
  async function versionsFor(client, platform) {
45
45
  const { json } = await client.get(
46
46
  `/v1/apps/${client.appId}/appStoreVersions?filter[platform]=${platform}&limit=200` +
47
- `&fields[appStoreVersions]=versionString,appStoreState`,
47
+ // `copyright` is requested because syncCopyright compares against it. A sparse fieldset that
48
+ // omits it does not read as absent — it reads as EMPTY, so the comparison can never match and
49
+ // the field is re-PATCHed on every run, spending a request to change nothing and printing
50
+ // "(was empty)" over a value that was already correct. A guard that cannot see the thing it
51
+ // guards is not a guard.
52
+ `&fields[appStoreVersions]=versionString,appStoreState,copyright`,
48
53
  );
49
54
  return json.data || [];
50
55
  }
@@ -70,6 +75,46 @@ function readCopyright(config) {
70
75
  return fs.readFileSync(p, "utf8").trim() || null;
71
76
  }
72
77
 
78
+ /**
79
+ * Copyright on a version that ALREADY EXISTED.
80
+ *
81
+ * It was only ever set at creation, on the reasoning below that nothing else in vydanne writes this
82
+ * version-level field — which was true and yet left the gap wide open, because `prepare` is
83
+ * find-or-CREATE. Every app whose draft was made before `copyright.txt` existed, or made by hand in
84
+ * App Store Connect, or created by an older vydanne, reused that version forever and never got a
85
+ * copyright. The tooling then reported "prepare done — the version is editable" and the operator
86
+ * discovered the truth from Apple, at the end, as `Copyright - This field is required` on the Add
87
+ * for Review screen. The comment three lines down even called it "a metadata rejection waiting to
88
+ * happen".
89
+ *
90
+ * So the create path and the reuse path now converge: after this, a version has the copyright the
91
+ * repo declares, however that version came to exist.
92
+ *
93
+ * PATCHes only on a real difference. Apple's rate limits are per-app and a `prepare` that rewrites
94
+ * an already-correct field on every `push` spends a request to change nothing — and, more to the
95
+ * point, prints a line claiming it did something.
96
+ */
97
+ async function syncCopyright(client, config, version) {
98
+ const want = readCopyright(config);
99
+ if (!want) return;
100
+ const have = version.attributes?.copyright ?? "";
101
+ if (have === want) return;
102
+
103
+ const was = have ? ` (was "${have}")` : " (was empty)";
104
+ const r = await client.patch(`/v1/appStoreVersions/${version.id}`, {
105
+ data: { type: "appStoreVersions", id: version.id, attributes: { copyright: want } },
106
+ });
107
+ if (r.status >= 300) {
108
+ console.error(red(` could not set copyright (${r.status})`));
109
+ for (const e of r.json?.errors || []) console.error(` ${e.title}: ${e.detail}`);
110
+ return;
111
+ }
112
+ version.attributes.copyright = want;
113
+ console.log(client.dryRun
114
+ ? yellow(` WOULD set copyright: "${want}"${was}`)
115
+ : green(` copyright set: "${want}"${was}`));
116
+ }
117
+
73
118
  /**
74
119
  * Is `a` a higher marketing version than `b`?
75
120
  *
@@ -117,6 +162,7 @@ export async function ensureVersion(client, config, platform, versionString) {
117
162
  return null;
118
163
  }
119
164
  console.log(green(` version ${versionString} already exists (${state}) — reusing it`));
165
+ await syncCopyright(client, config, existing);
120
166
  return existing;
121
167
  }
122
168
 
package/src/config.mjs CHANGED
@@ -8,7 +8,7 @@ import { DEFAULT_PLAY_IMAGES } from "./play/images.mjs";
8
8
 
9
9
  // The public config surface — the drift guards assert each key is documented (README/SKILL) and typed
10
10
  // (types/index.d.ts). Add a config knob → document + type it, or the guards fail before publish.
11
- export const CONFIG_KEYS = ["bundleId", "primaryLocale", "asc", "platforms", "uiLocales", "localeMap", "metadataDir", "screenshots", "rating", "ageRating", "privacy", "iaps", "previews", "export", "ios", "google", "accessibility", "bridge", "push", "reviewContact", "allowCrossStoreTerms"];
11
+ export const CONFIG_KEYS = ["bundleId", "primaryLocale", "asc", "platforms", "uiLocales", "localeMap", "metadataDir", "screenshots", "rating", "ageRating", "categories", "contentRights", "privacy", "iaps", "previews", "export", "ios", "google", "accessibility", "bridge", "push", "reviewContact", "allowCrossStoreTerms"];
12
12
 
13
13
  // One `vydanne.config.mjs` per app (ESM, like zdymak.config.mjs) — nothing hard-coded. Secrets stay out:
14
14
  // credentials resolve from the environment, a gitignored .env, or ~/.appstoreconnect/config.json (see
@@ -44,6 +44,12 @@ export async function loadConfig(p) {
44
44
  // Content descriptors for a rating above 4+. Null means "the 4+ shorthand", which `age-rating`
45
45
  // expands to an all-NONE declaration; anything else must be declared, never guessed.
46
46
  ageRating: raw.ageRating || null,
47
+ // App Store category, and whether the app shows third-party content. Both block Add for Review
48
+ // and neither has a safe default: a category guessed on the app's behalf is a shelf it did not
49
+ // choose, and a content-rights answer guessed on its behalf is a DECLARATION TO APPLE nobody
50
+ // made. So both stay null/undefined until declared, and `appinfo` refuses rather than assumes.
51
+ categories: raw.categories || null,
52
+ contentRights: raw.contentRights,
47
53
  privacy: raw.privacy || { collected: ["CRASH_DATA", "PERFORMANCE_DATA"], tracking: false },
48
54
  iaps: raw.iaps || [],
49
55
  metadataDir: raw.metadataDir || "fastlane/metadata",
package/src/registry.mjs CHANGED
@@ -19,6 +19,7 @@ export const COMMANDS = {
19
19
  // written to a live listing while everyone re-derived it. Stops at the first failure; never submits.
20
20
  push: { mod: "push", client: true, writes: true },
21
21
  fill: { mod: "fill", client: true, writes: true },
22
+ appinfo: { mod: "appInfo", client: true, writes: true },
22
23
  "age-rating": { mod: "ageRating", client: true, writes: true },
23
24
  "review-contact": { mod: "reviewContact", client: true, writes: true },
24
25
  accessibility: { mod: "accessibility", client: true, writes: true },
package/types/index.d.ts CHANGED
@@ -19,6 +19,11 @@ export type CommandName =
19
19
  */
20
20
  | 'push'
21
21
  | 'fill'
22
+ /**
23
+ * The two app-level facts that block **Add for Review** and belong to no single release: the App
24
+ * Store category, and whether the app shows third-party content. Set once; re-running is a no-op.
25
+ */
26
+ | 'appinfo'
22
27
  | 'age-rating'
23
28
  | 'review-contact'
24
29
  | 'accessibility'
@@ -68,6 +73,19 @@ export interface PreviewSpec {
68
73
  locales?: string[];
69
74
  }
70
75
 
76
+ /**
77
+ * Apple's category ids, not display names: upper snake case, and game subcategories are `GAMES_*`
78
+ * ('GAMES_PUZZLE', never 'Puzzle'). Apple takes up to two subcategories per category.
79
+ */
80
+ export interface CategoriesConfig {
81
+ primary: string;
82
+ primarySubcategoryOne?: string;
83
+ primarySubcategoryTwo?: string;
84
+ secondary?: string;
85
+ secondarySubcategoryOne?: string;
86
+ secondarySubcategoryTwo?: string;
87
+ }
88
+
71
89
  export interface PrivacyConfig {
72
90
  /** ASC data-type ids, e.g. ['CRASH_DATA', 'PERFORMANCE_DATA']. */
73
91
  collected: string[];
@@ -268,6 +286,17 @@ export interface VydanneConfig {
268
286
  rating?: string;
269
287
  /** Content descriptors for a rating above '4+'. Merged over an all-NONE base. */
270
288
  ageRating?: AgeRatingConfig;
289
+ /**
290
+ * App Store category, written by `appinfo`. Apple blocks Add for Review without a primary one, and
291
+ * subcategories are valid only under `'GAMES'`.
292
+ */
293
+ categories?: CategoriesConfig;
294
+ /**
295
+ * Does the app contain, show or access THIRD-PARTY content? Also blocks Add for Review. Left
296
+ * undefined it is not written at all, so an answer already given in App Store Connect is never
297
+ * overwritten by a default nobody chose — this is a declaration to Apple, not a preference.
298
+ */
299
+ contentRights?: boolean;
271
300
  privacy?: PrivacyConfig;
272
301
  iaps?: IapConfig[];
273
302
  previews?: PreviewSpec[];