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 +3 -2
- package/SKILL.md +20 -2
- package/bin/vydanne.mjs +4 -0
- package/package.json +3 -2
- package/src/commands/appInfo.mjs +124 -0
- package/src/commands/diff.mjs +6 -1
- package/src/commands/fill.mjs +8 -2
- package/src/commands/prepare.mjs +47 -1
- package/src/config.mjs +7 -1
- package/src/registry.mjs +1 -0
- package/types/index.d.ts +29 -0
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.
|
|
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
|
-
"
|
|
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
|
+
}
|
package/src/commands/diff.mjs
CHANGED
|
@@ -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
|
-
|
|
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; };
|
package/src/commands/fill.mjs
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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.
|
package/src/commands/prepare.mjs
CHANGED
|
@@ -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
|
-
|
|
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[];
|