vydanne 0.6.0 → 0.8.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/SKILL.md CHANGED
@@ -11,8 +11,12 @@ fastlane/Ruby/Python. Two jobs: **(A) write the listing well (ASO)**, **(B) push
11
11
  **Companion tool — [zdymak](https://www.npmjs.com/package/zdymak)** makes the *media* (screenshots, App
12
12
  Preview videos, Play feature graphic); vydanne pushes that media plus all the *text and paperwork*. If the
13
13
  user needs screenshots or a preview video produced, that's zdymak's job, not vydanne's — vydanne only
14
- uploads files that already exist. zdymak's default output paths are exactly the paths vydanne reads for
15
- Play images (below), so the two line up with no glue.
14
+ uploads files that already exist. **The two do NOT line up on disk** (they did before zdymak 0.15):
15
+ zdymak writes one root shaped `store-assets/<locale>/<target>/NN-name.png`, while vydanne reads several
16
+ hardcoded roots, with Apple's locale codes and a device-prefix filename convention. **`vydanne bridge`
17
+ is the glue** — run it after `zdymak screenshots`/`zdymak build` and before `fill`, every time.
18
+ Skipping it is silent and dangerous: zdymak reports success, vydanne re-uploads whatever was bridged
19
+ LAST time, and the store quietly keeps stale art.
16
20
 
17
21
  **Never submits for review; never ships to the public.** It *does* upload builds — `prerelease` sends
18
22
  the `.ipa` to TestFlight (internal groups) or the `.aab` to a Play closed track, and points the version
@@ -51,8 +55,30 @@ which user file was used, and whether the `.p8` is on disk.
51
55
 
52
56
  **Config fields:** `bundleId` · `primaryLocale` (the fallback — must be populated) · `asc` (optional
53
57
  `{profile}` — selection only, never secrets) · `platforms` (iOS and macOS are SEPARATE) · `uiLocales`
54
- (auto-mapped to ASC codes) · `metadataDir` · `rating` · `privacy` · `iaps` · `previews` · `export` ·
55
- `google` (Google Play).
58
+ (auto-mapped to ASC codes) · `localeMap` · `metadataDir` · `screenshots` · `rating` · `ageRating` ·
59
+ `privacy` · `iaps` · `previews` · `export` · `accessibility` · `ios` · `google` (Google Play) ·
60
+ `bridge` · `push` · `reviewContact` · `allowCrossStoreTerms`.
61
+
62
+ **Paths are defaults, not laws.** `metadataDir` (default `fastlane/metadata`), `screenshots`
63
+ (`{IOS, MAC_OS}`, default `fastlane/screenshots` + `-macos`) and `google.images` (Play image type →
64
+ local path) all follow fastlane's supply convention out of the box and are all overridable. Point them
65
+ at the repo you have rather than reshaping the repo around the tool.
66
+
67
+ **Four blocks the tool will NOT fill in for you.** Each publishes a CLAIM rather than a fact, so silence
68
+ is refused instead of defaulted — that is deliberate, and re-adding a default is the bug, not the fix:
69
+
70
+ - **`accessibility`** — Accessibility Nutrition Labels. Declare every feature true/false from what was
71
+ actually verified. No block → the command errors.
72
+ - **`export.algorithms` + `export.statement`** — the cryptography inventory and statement in the US
73
+ export-compliance PDF (`compliance`). Required when `export.encryption` is `"standard"`. Never invent
74
+ these; ask what the app actually ships. `export.filed` stays **false** until the report has really
75
+ been emailed to BIS and the NSA, and while it is false the PDF does not claim it was submitted.
76
+ - **`ageRating`** — needed for any `rating` above `"4+"`. Describe the CONTENT
77
+ (`{ violenceCartoonOrFantasy: "INFREQUENT_OR_MILD" }`, merged over an all-NONE base); Apple computes
78
+ the band. `"4+"` alone needs nothing else.
79
+ - **`reviewContact` / demo account** — whether App Review needs a login is inferred from
80
+ `<metadataDir>/review_information/demo_user.txt` + `demo_password.txt` (both gitignored). An app with
81
+ a sign-in wall and no demo account is a guaranteed rejection.
56
82
 
57
83
  For a non-technical user asking how to set this up from scratch, walk them through
58
84
  **`GETTING_STARTED.md`** (accounts → API key → config → folders → push) rather than improvising.
@@ -94,17 +120,38 @@ before the **first underscore** selects the device slot; files upload in sorted
94
120
  | `watch_` | `APP_WATCH_ULTRA` |
95
121
  | `macos_` | `APP_DESKTOP` (in `screenshots-macos/`) |
96
122
 
97
- An unknown prefix is silently ignored. A set that **already has screenshots is skipped**, never
98
- duplicated — to replace shots, delete them in ASC first. PNGs must be **RGB with no alpha**.
123
+ A file with an unknown prefix is not uploaded, and `fill` names it. A set that **already has
124
+ screenshots is skipped** (and says so), never duplicated — `VYDANNE_REPLACE=1` deletes the store's set
125
+ and uploads yours, the same flag `previews` uses. PNGs must be **RGB with no alpha**.
99
126
 
100
127
  **Play listing text** — `<google.metadataDir>/<PLAY-locale>/{title,short_description,full_description}.txt`
101
128
  (30 / 80 / 4000). Play uses its **own** codes (`de-DE`, `zh-CN`, `iw-IL`, `ar`, `be`) — *not* Apple's
102
129
  `zh-Hans`/`he`/`ar-SA`.
103
130
 
104
- **Play images** — hardcoded source paths (zdymak's output), each pushed only when the file exists, so a
105
- missing local set never deletes the live one: `brand/icons/play/icon-512.png` (512²) ·
106
- `marketing/out/play-feature-graphic.png` (1024×500) · `marketing/out/play-phone-plain/` ·
107
- `marketing/out/play-tablet7-plain/` (7″) · `marketing/out/play-tablet-plain/` (10″).
131
+ **Play images** — DEFAULT source paths (override any of them with `google.images`), each pushed only
132
+ when the file exists, so a missing local set never deletes the live one:
133
+ `brand/icons/play/icon-512.png` (512², from znachok) · `marketing/out/play-feature-graphic.png`
134
+ (1024×500) · `marketing/out/play-phone-plain/` · `marketing/out/play-tablet7-plain/` (7″) ·
135
+ `marketing/out/play-tablet-plain/` (10″) · `marketing/out/play-wear/`. `wearScreenshots`, `tvScreenshots`
136
+ and `tvBanner` are also understood, so a Wear OS or Android TV release is a config line, not a code
137
+ change. An image dir that exists but is EMPTY is reported (by `fill` and `diff`) as a live set only
138
+ Play Console can remove.
139
+
140
+ Play holds graphics **per language**. The default uploads one untranslated set at
141
+ `google.defaultLocale`; `google.imageLocales` (a list, or `"*"` for every local listing folder) opts
142
+ into localized art, and a `<source>/<lang>/` subdirectory overrides the shared source for that language.
143
+ `diff --store google` compares exactly the locales `fill` would write, so the two never disagree.
144
+
145
+ **`bridge` populates both screenshot layouts from zdymak's output.** It maps
146
+ `store-assets/<locale>/<dir>/NN-name.png` onto the Apple and Play paths above: locale codes via the
147
+ same `toAsc` table `fill` uses (`de` → `de-DE`; a code with no App Store language is skipped and falls
148
+ back to the primary listing), the device-slot prefix prepended (`iphone69_01-fresh.png`), and the Play
149
+ sets into their configured destinations. A locale with screenshots but NO listing text is held back
150
+ (uploading pictures alone would create the localization and break its fallback to the primary
151
+ language), and every bridged image is checked for an alpha channel — which Apple rejects — on the
152
+ SOURCE, before anything is copied, so a refusal leaves the destinations untouched and `--dry-run`
153
+ catches it too. Local files only; `--dry-run` previews what would be written *and removed*. See the
154
+ directory-vs-target note under Commands for why the source folder name is the thing that matters.
108
155
 
109
156
  ## A. Writing the listing (the ASO craft — the durable value)
110
157
 
@@ -163,10 +210,22 @@ who it's for → honest close. Keep it scannable; lead each bullet with the payo
163
210
  ## B. Commands (push it)
164
211
 
165
212
  `fill` (metadata + screenshots, native PATCH/chunked upload — works even at READY_FOR_REVIEW) ·
166
- `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
167
214
  live) · `privacy` (prints answers for the UI — the API can't reach Apple's iris host) · `iap` (validate +
168
- RGB flatten) · `compliance` (US self-classification PDF) · `diff` (what differs vs live) · `preflight`
169
- (completeness gate + cross-store lint) · `inspect` · `auth` (what credentials resolved, and from where) · `locales` · `version`.
215
+ RGB flatten) · `compliance` (US self-classification PDF) · `bridge` (zdymak's output → the folders
216
+ `fill` reads) · `diff` (what differs vs live, text AND media by checksum) · `preflight`
217
+ (completeness gate + cross-store lint + stale-screenshot check) · `inspect` · `auth` (what credentials
218
+ resolved, and from where) · `locales` · `version`.
219
+
220
+ **`bridge` maps by DIRECTORY, not by target.** zdymak writes each shot to `<dir || target>`, so a
221
+ `dir:` override makes the folder name differ from the target name — and Play's 7" slot can *only* exist
222
+ that way (`{ target: 'play-tablet', dir: 'play-tablet7-plain' }`; there is no `play-tablet7` target).
223
+ Defaults prefer the `-plain` convention Google asks for on listings and fall back to the bare target
224
+ name; `bridge.apple` / `bridge.play` override per slot. It **owns its destinations per store**: any
225
+ Apple output means both Apple roots are rebuilt (so art dropped upstream stops being uploaded), while
226
+ an app that bridges only Play never has its hand-managed Apple screenshots touched. It plans before it
227
+ writes — a failure (alpha channel, empty source) leaves every destination untouched, and `--dry-run`
228
+ reports exactly what a real run would write *and remove*.
170
229
 
171
230
  **Cross-store lint.** `preflight` and `fill` refuse listing text that names the other mobile platform
172
231
  — App Review 2.3.10 for Apple, the Store Listing and Promotion policy for Google — scanning every
@@ -176,6 +235,43 @@ blocking; `allowCrossStoreTerms: [...]` in the config silences a specific one, a
176
235
  `VYDANNE_ALLOW_CROSS_STORE=1` overrides a single run. This is a rejection that surfaces days later in
177
236
  one locale out of twenty, so it is checked where it is free to fix.
178
237
 
238
+ **The pipeline has ONE working order** — `prepare` → `fill` → `previews` → `age-rating` →
239
+ `review-contact` → `accessibility` → `preflight` → **a human submits**. `prepare` must be first (until
240
+ the draft version exists, nothing has anywhere to write) and `preflight` last (green must be measured
241
+ after the writes it blesses). `push` runs exactly that sequence — each step the same `run` as the
242
+ standalone command, on the same client, stopping at the first failure — so prefer `vydanne push` over
243
+ re-deriving the order; the near-miss that motivated it was `fill` pointed at a live-only app out of
244
+ order. The two flows differ only at step one and `push` absorbs it: a FIRST release already has a
245
+ PREPARE_FOR_SUBMISSION version (creating the app record made it), so `prepare` is a find-and-reuse
246
+ no-op; an UPDATE has only the read-only live version until `prepare` creates the next one. On a live
247
+ app a DRY `push` stops at `fill` — the draft the later steps target doesn't exist until `prepare` is
248
+ applied — and says so up front; `prepare --apply` (a draft, not a submission) then a dry `push`
249
+ previews the whole plan. `prepare` creates a version for EVERY declared platform, so an iOS+macOS app
250
+ gets both drafts. `prerelease` is deliberately not a step (macOS-only, shells out to altool); run it
251
+ whenever the build is ready — `prepare` attaches the newest build either way.
252
+
253
+ **`push --skip <step>[,<step>]`** (or `push: { skip: [...] }`) drops a step that doesn't apply — the
254
+ usual case being an app with no audited `accessibility` block, which the command correctly refuses to
255
+ guess. `prepare` and `preflight` cannot be skipped. Every skip is reported on its own line AND again
256
+ after the final green, because the whole value of that last line is that green means green: never let
257
+ a skipped run read like a complete one.
258
+
259
+ `prepare` creates the version to write INTO, and is REQUIRED as the first push step on any app that
260
+ already has a version on sale. Every other Apple command finds its target through
261
+ `client.editVersion()`, which returns the first version Apple has not marked dead; when the only
262
+ version is live it has no editable record to return and falls back to the live one — so `fill` aims
263
+ its `description`/`whatsNew` PATCHes at the listing customers are reading, and `prerelease` declines
264
+ to attach the build it just uploaded. `prepare` POSTs `/v1/appStoreVersions` with `releaseType:
265
+ MANUAL` (so approval still doesn't release), sets `copyright` from `<metadataDir>/copyright.txt`
266
+ (nothing else in vydanne writes that version-level field), and attaches the newest build. The version
267
+ number is read off that build's `preReleaseVersion` — the archive's own
268
+ `CFBundleShortVersionString`, so it cannot drift from the binary — or `VYDANNE_VERSION=<x>` when the
269
+ version is being prepared before its build exists. It is find-or-create, so re-running is safe; a
270
+ version Apple has locked (`IN_REVIEW`, `READY_FOR_SALE`, …) is refused rather than edited, because
271
+ withdrawing a submission is the operator's call. It pre-checks the number against the version on sale,
272
+ turning Apple's 409 into a sentence. **Creating a draft is not submitting** — Add to Review and Submit
273
+ stay manual.
274
+
179
275
  `prerelease` uploads the BUILD. On Apple it validates and uploads the `.ipa` to **TestFlight** via
180
276
  `xcrun altool` — the one command that shells out, because the ASC REST API has never carried a binary,
181
277
  which also makes it macOS-only. `.ipa` comes from `ios.ipa` / `VYDANNE_IPA` (a directory takes its
@@ -191,10 +287,14 @@ edit transaction. `production` is REFUSED — not flag-gated — so no argument
191
287
  public; promoting the tested build stays a human's job, mirroring the Apple side never submitting. Track
192
288
  comes from `google.track` / `VYDANNE_TRACK`, default `internal`; the bundle from `google.aab` /
193
289
  `VYDANNE_AAB` (a directory takes its newest `.aab`). **For a PAID app use `internal`** — it's the only
194
- track where testers install without buying. Notes follow supply's layout:
195
- `<google.metadataDir>/<play-locale>/changelogs/<versionCode>.txt`, falling back to `default.txt`, capped
196
- at Play's 500 chars. DRY by default; `--apply` publishes. The versionCode comes from the bundle itself,
197
- so re-uploading one fails loudly instead of silently replacing.
290
+ track where testers install without buying. Notes follow supply's layout, per locale, first match wins:
291
+ `<google.metadataDir>/<play-locale>/changelogs/<versionCode>.txt` → `next.txt` → `default.txt`, capped
292
+ at Play's 500 chars. **Write the upcoming release's notes as `next.txt`** when the versionCode is not
293
+ knowable in advance (derived from the commit count, say): after a real publish vydanne renames it to
294
+ `<versionCode>.txt` so the next release can't inherit it, and a `default.txt` fallback is WARNED rather
295
+ than silent. The versionCode is read out of the `.aab` locally and the changelog resolution reported
296
+ BEFORE the upload; re-uploading a used code fails loudly instead of silently replacing. DRY by default;
297
+ `--apply` publishes.
198
298
 
199
299
  `--store google` routes `inspect` · `diff` · `preflight` · `fill` · `prerelease` to the Play Developer **Edits** API
200
300
  (OAuth2 service account; **scoped to the config's `packageName`** — a shared key can't touch another app).
@@ -202,9 +302,10 @@ The AAB binary and the (YouTube-URL) promo video stay outside vydanne.
202
302
 
203
303
  ## `--apply` — writes are opt-in
204
304
 
205
- **Every store-mutating command is a DRY RUN without `--apply`**: `fill` · `previews` · `age-rating` ·
206
- `review-contact` · `accessibility` · `prerelease` (marked `✎` in `vydanne help`). They read the store,
207
- print each write they would make, and send nothing. Read-only commands ignore the flag.
305
+ **Every store-mutating command is a DRY RUN without `--apply`**: `prepare` · `push` · `fill` ·
306
+ `previews` · `appinfo` · `age-rating` · `review-contact` · `accessibility` · `prerelease` (marked `✎` in
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
+ commands ignore the flag.
208
309
 
209
310
  Never reach for `--apply` to "check whether it works" — the dry run IS the check, and it walks the whole
210
311
  plan rather than stopping at the first locale. Its closing count is what you compare against `diff`.
@@ -217,14 +318,18 @@ through that client. **A new command that touches the store must be marked `writ
217
318
  `src/registry.mjs`** — that flag is the whole opt-in, not a label.
218
319
 
219
320
  **Env toggles:** `VYDANNE_CONFIG` · `VYDANNE_SKIP_METADATA` / `VYDANNE_SKIP_SCREENSHOTS` (fill) ·
220
- `VYDANNE_REPLACE` (previews) · `VYDANNE_FLATTEN=<png>` (iap) · `VYDANNE_A11Y_PUBLISH` (accessibility) ·
221
- `VYDANNE_COMMIT=1` (legacy alias for `--apply`; prefer the flag).
321
+ `VYDANNE_REPLACE` (fill screenshots + previews: replace populated slots) · `VYDANNE_VERSION` (prepare) ·
322
+ `VYDANNE_IPA` / `VYDANNE_AAB` / `VYDANNE_TRACK` / `VYDANNE_RELEASE_NAME` (prerelease) ·
323
+ `VYDANNE_FLATTEN=<png>` (iap) · `VYDANNE_A11Y_PUBLISH` (accessibility) · `VYDANNE_ALLOW_CROSS_STORE`
324
+ (one run past the cross-store lint) · `VYDANNE_COMMIT=1` (legacy alias for `--apply`; prefer the flag).
222
325
 
223
326
  ## Flow
224
327
 
225
- config → **write the English master listing (ASO, research-grounded)** → `preflight` (char limits) → fan
226
- out one copywriter agent per locale → media from zdymak → `fill` + `previews` + declarations (read the
227
- dry run, then re-run with `--apply`) → `diff` → `preflight` (must be green) → **a human submits**.
328
+ config → **write the English master listing (ASO, research-grounded)** → fan out one copywriter agent
329
+ per locale → media from zdymak → **`bridge`** (zdymak's output into vydanne's folders — every time) →
330
+ build via `prerelease --apply` whenever it is ready → `push` (read the dry run, then re-run with
331
+ `--apply` — it is prepare → fill → previews → age-rating → review-contact → accessibility → preflight,
332
+ stopping at the first failure) → `diff` to confirm → **a human submits**.
228
333
 
229
334
  ## Gotchas it encodes (don't re-derive)
230
335
 
@@ -238,3 +343,21 @@ promo) · char limits (name/subtitle 30, keywords 100, promo 170; IAP name 30 /
238
343
  short 80 / full 4000) · **Play uses its OWN locale codes** · Play images push only when the local file
239
344
  exists, so a missing set never wipes the live one · Apple requires `name` when *creating* an app-info
240
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
@@ -15,6 +15,14 @@ const i = argv.indexOf("--config");
15
15
  const cfgPath = i >= 0 ? argv[i + 1] : undefined;
16
16
  const si = argv.indexOf("--store");
17
17
  const store = si >= 0 ? argv[si + 1] : "apple";
18
+ // Validated, because the dispatch below only tests `store === "google"`: a typo (`--store goole`) or a
19
+ // missing value (`--store --apply`) would otherwise fall through to the APPLE branch and aim the command
20
+ // at the other store — the worst possible reading of a mistyped argument, and a silent one, since every
21
+ // Apple command runs happily from a repo configured for both.
22
+ if (store !== "apple" && store !== "google") {
23
+ console.error(`\x1b[31mvydanne: unknown --store '${store}' (expected: apple, google)\x1b[0m`);
24
+ process.exit(1);
25
+ }
18
26
 
19
27
  // SAFE BY DEFAULT. Every store-mutating command is a dry run unless `--apply` is passed. The Play half
20
28
  // always worked this way (VYDANNE_COMMIT=1, enforceable because an Edit can be discarded); the Apple half
@@ -57,6 +65,9 @@ try {
57
65
  console.log(`supported (${Object.keys(r.supported).length}):`);
58
66
  for (const [ui, asc] of Object.entries(r.supported)) console.log(` ${ui} -> ${asc}`);
59
67
  console.log(`unsupported (${r.unsupported.length}) [no App Store language -> fall back to primary]: ${r.unsupported.join(", ")}`);
68
+ // A localeMap entry pointing at a code Apple does not have is a config mistake, not a missing
69
+ // language — it would otherwise be indistinguishable from the line above.
70
+ if (r.invalid?.length) console.log(`\x1b[31minvalid localeMap (${r.invalid.length}) [not an App Store code]: ${r.invalid.join(", ")}\x1b[0m`);
60
71
  } else if (store === "google") {
61
72
  const cfg = await loadConfig(cfgPath);
62
73
  if (!cfg.google) throw new Error("vydanne: no `google` block in config — add packageName + a service-account key");
@@ -107,6 +118,15 @@ usage: vydanne <command> [--apply] [--config vydanne.config.mjs]
107
118
  --apply PERFORM the writes. Without it every store-mutating command below (marked ✎) runs
108
119
  as a DRY RUN: it reads the store, reports exactly what it would change, and sends
109
120
  nothing. Read-only commands ignore the flag.
121
+ ✎ prepare create/reuse the editable App Store version and attach the uploaded build.
122
+ Run this FIRST on an app that already has a version on sale — until it has,
123
+ there is no draft for \`fill\` to write into. Never submits; VYDANNE_VERSION=<x>
124
+ to name the version instead of reading it off the newest build.
125
+ ✎ push the whole pipeline, in order: prepare → fill → previews → age-rating →
126
+ review-contact → accessibility → preflight. Stops at the first failure.
127
+ --skip <step>[,<step>] drops steps that don't apply (prepare/preflight can't be
128
+ skipped); every skip is reported again at the end, so green still means green.
129
+ Ends at a green preflight — Add to Review + Submit stay yours, in the web UI.
110
130
  ✎ fill metadata + screenshots + previews (native; iOS & macOS separate)
111
131
  ✎ age-rating set the age rating (AppInfo declaration)
112
132
  ✎ review-contact App Review contact from the gitignored files
@@ -115,6 +135,8 @@ usage: vydanne <command> [--apply] [--config vydanne.config.mjs]
115
135
  ✎ previews upload App Preview videos (native chunked upload)
116
136
  iap validate IAP fields; VYDANNE_FLATTEN=<png> flattens a screenshot to RGB
117
137
  compliance generate the US encryption self-classification PDF
138
+ bridge map zdymak's store-assets output onto the folders \`fill\` reads (locale codes,
139
+ device prefixes, Play paths). Local files only; --dry-run previews.
118
140
  inspect read-only ASC state
119
141
  diff show what differs between local (metadata/screenshots/previews) and ASC
120
142
  preflight verify submission-completeness (the gotcha checker)
@@ -125,7 +147,8 @@ usage: vydanne <command> [--apply] [--config vydanne.config.mjs]
125
147
  credentials: env > .env cascade (.env, .env.<mode>, .env.local, .env.<mode>.local) > user config
126
148
  (\$VYDANNE_CONFIG_HOME, %APPDATA%\\vydanne or \$XDG_CONFIG_HOME/vydanne, ~/.appstoreconnect).
127
149
  NEVER the committed vydanne.config.mjs — run \`vydanne auth\` to see what resolved.
128
- toggles: VYDANNE_SKIP_METADATA / VYDANNE_SKIP_SCREENSHOTS (fill), VYDANNE_A11Y_PUBLISH (accessibility),
150
+ toggles: VYDANNE_SKIP_METADATA / VYDANNE_SKIP_SCREENSHOTS (fill), VYDANNE_REPLACE=1 (fill/previews:
151
+ replace populated slots), VYDANNE_VERSION (prepare), VYDANNE_A11Y_PUBLISH (accessibility),
129
152
  VYDANNE_PROFILE (named profile), VYDANNE_ENV (.env mode),
130
153
  VYDANNE_COMMIT=1 (legacy alias for --apply — prefer the flag)`;
131
154
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vydanne",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
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",
@@ -51,7 +51,9 @@
51
51
  "scripts": {
52
52
  "check:docs": "node scripts/check-docs.mjs",
53
53
  "check:types": "tsc --noEmit -p tsconfig.json && node scripts/check-types.mjs",
54
- "prepublishOnly": "npm run check:docs && npm run check:types",
54
+ "check:config": "node scripts/check-config.mjs",
55
+ "verify": "npm run check:docs && npm run check:types && npm run check:config",
56
+ "prepublishOnly": "npm run verify",
55
57
  "release:patch": "node scripts/release.mjs patch",
56
58
  "release:minor": "node scripts/release.mjs minor",
57
59
  "release:major": "node scripts/release.mjs major"
package/src/client.mjs CHANGED
@@ -3,13 +3,15 @@ import { yellow } from "./util.mjs";
3
3
 
4
4
  const API = "https://api.appstoreconnect.apple.com";
5
5
  const IRIS = "https://appstoreconnect.apple.com/iris";
6
- const DEAD_VERSION = ["READY_FOR_SALE", "REMOVED_FROM_SALE", "REPLACED_WITH_NEW_VERSION"];
6
+ // DEVELOPER_REMOVED_FROM_SALE is the state a version reaches when the DEVELOPER pulls it, as opposed to
7
+ // Apple; both are shipped versions that will never become editable again, so both are dead here.
8
+ const DEAD_VERSION = ["READY_FOR_SALE", "REMOVED_FROM_SALE", "DEVELOPER_REMOVED_FROM_SALE", "REPLACED_WITH_NEW_VERSION"];
7
9
  // READY_FOR_DISTRIBUTION is Apple's newer name for the LIVE app-info (they're migrating the
8
10
  // state vocabulary; versions still report appStoreState=READY_FOR_SALE). Without it here,
9
11
  // appInfo() treats the live record as editable and every name/subtitle PATCH comes back
10
12
  // ENTITY_ERROR.ATTRIBUTE.INVALID.INVALID_STATE — which fails the whole locale in `fill`,
11
13
  // release notes included, on any app that already has a version on sale.
12
- const DEAD_INFO = ["READY_FOR_SALE", "READY_FOR_DISTRIBUTION", "REPLACED_WITH_NEW_VERSION", "REMOVED_FROM_SALE"];
14
+ const DEAD_INFO = ["READY_FOR_SALE", "READY_FOR_DISTRIBUTION", "REPLACED_WITH_NEW_VERSION", "REMOVED_FROM_SALE", "DEVELOPER_REMOVED_FROM_SALE"];
13
15
 
14
16
  // Anything that is not a read. ASC has no transaction to roll back — unlike Play, where an edit can be
15
17
  // discarded — so for Apple the only safe place to stand between a command and a live listing is here.
@@ -79,16 +81,50 @@ export class Client {
79
81
  return app;
80
82
  }
81
83
 
82
- async editVersion(platform) {
84
+ /**
85
+ * The version being PREPARED — or null when there isn't one.
86
+ *
87
+ * This used to fall back to `data[0]`, the LIVE version, whenever nothing editable existed. The
88
+ * fallback was silent and it defeated its own callers: `fill`, `preflight`, `reviewContact` and
89
+ * `previews` each test `if (!v)` and report "no editable version", and not one of those branches could
90
+ * be reached on an app that had shipped once. What happened instead was worse than an error — `fill`
91
+ * aimed its description/whatsNew PATCHes at the listing customers were reading, and `preflight`
92
+ * validated that same live listing and printed "no blockers", calling a release submittable when
93
+ * there was nothing to submit.
94
+ *
95
+ * So a write is never handed the live version by default. Read-only commands (`inspect`, `diff`) pass
96
+ * `allowLive: true`, because "how does local compare with what is on sale" is a real question and that
97
+ * is the only version they could ask it about. Everything else gets null and says so — and now has
98
+ * `prepare` to point at, which is the command that makes an editable version exist.
99
+ */
100
+ async editVersion(platform, { allowLive = false } = {}) {
83
101
  const { json } = await this.get(`/v1/apps/${this.appId}/appStoreVersions?filter[platform]=${platform}&limit=10`);
84
102
  const data = json.data || [];
85
- return data.find((v) => !DEAD_VERSION.includes(v.attributes.appStoreState)) || data[0] || null;
103
+ const editable = data.find((v) => !DEAD_VERSION.includes(v.attributes.appStoreState));
104
+ if (editable) return editable;
105
+ return allowLive ? data[0] || null : null;
86
106
  }
87
107
 
88
- async appInfo() {
108
+ /**
109
+ * The app-info being PREPARED (name/subtitle, age rating live on it) — or null when there isn't one.
110
+ *
111
+ * Same disease, same cure as editVersion() above: this fell back to `data[0]` — the LIVE app-info —
112
+ * whenever nothing editable existed, and the fallback aimed writes at the record customers see. It is
113
+ * how the DEAD_INFO bug was found in the first place (every name/subtitle PATCH against the live
114
+ * record comes back INVALID_STATE and fails the whole locale in `fill`), and adding
115
+ * READY_FOR_DISTRIBUTION to DEAD_INFO only fixed the case where an editable sibling EXISTS to be
116
+ * found; the moment there is none, `|| data[0]` reintroduced exactly the state that comment warns
117
+ * about. Now a write gets null and a clear skip instead of twenty locales of ENTITY_ERROR.
118
+ *
119
+ * `allowLive` is for reads (`diff`), where "how does local compare with what is on sale" is the
120
+ * question being asked.
121
+ */
122
+ async appInfo({ allowLive = false } = {}) {
89
123
  const { json } = await this.get(`/v1/apps/${this.appId}/appInfos?limit=10`);
90
124
  const data = json.data || [];
91
- return data.find((i) => !DEAD_INFO.includes(i.attributes.state)) || data[0] || null;
125
+ const editable = data.find((i) => !DEAD_INFO.includes(i.attributes.state));
126
+ if (editable) return editable;
127
+ return allowLive ? data[0] || null : null;
92
128
  }
93
129
 
94
130
  async versionLocalizations(versionId) {
@@ -107,8 +107,14 @@ export async function run(config, client) {
107
107
  console.log(` declaring: ${claimed.length ? claimed.join(", ") : "(nothing)"}`);
108
108
 
109
109
  let gated = false;
110
+ // A PATCH that Apple refuses is a claim that never reached the store. Both failure paths below used
111
+ // to print red and `continue`, and the command still returned true — so `push` treated a family whose
112
+ // declaration never saved as a completed step. The `continue`s stay (one refused family must not hide
113
+ // the other three); the verdict now travels out with the return.
114
+ const failures = [];
110
115
  for (const family of Object.keys(UNAVAILABLE)) {
111
116
  const id = decls[family];
117
+ // Not a failure: Apple only holds declarations for the families the app actually ships on.
112
118
  if (!id) {
113
119
  console.error(yellow(` no ${family} declaration`));
114
120
  continue;
@@ -119,6 +125,7 @@ export async function run(config, client) {
119
125
  });
120
126
  if (r.status >= 300) {
121
127
  console.error(red(` ${family} draft ${r.status}`));
128
+ failures.push(`${family}: draft not saved (${r.status})`);
122
129
  continue;
123
130
  }
124
131
  if (publish) {
@@ -132,11 +139,17 @@ export async function run(config, client) {
132
139
  console.log(yellow(` ${family}: draft saved — publish deferred (app not live yet)`));
133
140
  } else {
134
141
  console.error(red(` ${family} publish ${p.status}`));
142
+ failures.push(`${family}: publish refused (${p.status})`);
135
143
  }
136
144
  } else {
137
145
  console.log(green(` ${family}: draft saved`));
138
146
  }
139
147
  }
148
+ if (failures.length) {
149
+ console.error(red(`accessibility: ${failures.length} declaration(s) did not save:`));
150
+ for (const f of failures) console.error(` ${red("x")} ${f}`);
151
+ return false;
152
+ }
140
153
  console.log(
141
154
  gated
142
155
  ? yellow("accessibility staged (DRAFT); re-run with VYDANNE_A11Y_PUBLISH=1 once the app is live")
@@ -1,33 +1,106 @@
1
1
  import { green, yellow, red } from "../util.mjs";
2
2
 
3
- // Set the age rating via the AppInfo age-rating declaration. v1: "4+" — every content descriptor NONE.
4
- // (PATCH is partial; Apple recomputes 4+ from these.) app-info fetched from the full list (survives
5
- // READY_FOR_REVIEW).
3
+ /**
4
+ * Set the age rating via the AppInfo age-rating declaration.
5
+ *
6
+ * `rating: "4+"` stays the shorthand it always was: every content descriptor NONE, every capability
7
+ * question false, which is what Apple recomputes 4+ from. Anything else is declared feature by feature
8
+ * in `ageRating`, merged over that all-NONE base — so an app with fantasy violence sets one key rather
9
+ * than restating twenty-seven.
10
+ *
11
+ * This used to refuse outright for any rating but 4+, which made the whole command — and `push`, which
12
+ * runs it as a step — unusable for any app with violence, gambling, chat or user-generated content.
13
+ * The base was always the general thing; only the door was missing.
14
+ */
15
+
16
+ const N = "NONE";
17
+
18
+ // Apple's 2025 age-rating schema. A PATCH must include ALL required fields (a partial set 409s), and
19
+ // `ageRatingOverride` (deprecated) cannot be sent alongside `ageRatingOverrideV2` — so we send only V2.
20
+ // Content descriptors are enums (NONE/INFREQUENT_OR_MILD/FREQUENT_OR_INTENSE); capability questions are
21
+ // booleans. All benign here → 4+.
22
+ const BASE = {
23
+ advertising: false, alcoholTobaccoOrDrugUseOrReferences: N, contests: N, gambling: false,
24
+ gamblingSimulated: N, gunsOrOtherWeapons: N, healthOrWellnessTopics: false, kidsAgeBand: null,
25
+ lootBox: false, medicalOrTreatmentInformation: N, messagingAndChat: false, parentalControls: false,
26
+ profanityOrCrudeHumor: N, ageAssurance: false, sexualContentGraphicAndNudity: N, sexualContentOrNudity: N,
27
+ socialMedia: false, socialMediaAgeRestricted: false, horrorOrFearThemes: N, matureOrSuggestiveThemes: N,
28
+ unrestrictedWebAccess: false, userGeneratedContent: false, violenceCartoonOrFantasy: N,
29
+ violenceRealisticProlongedGraphicOrSadistic: N, violenceRealistic: N, ageRatingOverrideV2: N,
30
+ koreaAgeRatingOverride: N,
31
+ };
32
+
33
+ /** Values Apple accepts for a content descriptor. `kidsAgeBand` and the overrides are checked separately. */
34
+ const DESCRIPTOR_VALUES = new Set([N, "INFREQUENT_OR_MILD", "FREQUENT_OR_INTENSE"]);
35
+
36
+ /**
37
+ * The attributes to send, or a human-readable problem.
38
+ *
39
+ * A declaration is validated against the schema BEFORE it reaches Apple, because the failure otherwise
40
+ * is a 409 naming a field the operator did not know existed. An unknown key is a typo — and a typo in
41
+ * this table means a descriptor silently stayed NONE, which is a rating that understates the app.
42
+ */
43
+ export function resolveAttributes(config) {
44
+ const declared = config.ageRating;
45
+ if (!declared) {
46
+ if (config.rating === "4+") return { attributes: { ...BASE } };
47
+ return {
48
+ problem: [
49
+ `age-rating: rating is '${config.rating}', but nothing describes what makes it that.`,
50
+ "'4+' is the only rating that needs no detail (every descriptor NONE). For anything else,",
51
+ "declare the content Apple asks about — it computes the rating from these, you don't set it:",
52
+ "",
53
+ " ageRating: {",
54
+ " violenceCartoonOrFantasy: 'INFREQUENT_OR_MILD',",
55
+ " userGeneratedContent: false,",
56
+ " },",
57
+ "",
58
+ `Known keys: ${Object.keys(BASE).join(", ")}`,
59
+ ].join("\n"),
60
+ };
61
+ }
62
+ const unknown = Object.keys(declared).filter((k) => !(k in BASE));
63
+ if (unknown.length) {
64
+ return { problem: `age-rating: unknown key(s) ${unknown.join(", ")}. Known: ${Object.keys(BASE).join(", ")}` };
65
+ }
66
+ const bad = [];
67
+ for (const [k, v] of Object.entries(declared)) {
68
+ if (k === "kidsAgeBand") continue; // null | FIVE_AND_UNDER | SIX_TO_EIGHT | NINE_TO_ELEVEN
69
+ if (typeof BASE[k] === "boolean" && typeof v !== "boolean") bad.push(`${k} must be true or false`);
70
+ else if (typeof BASE[k] === "string" && !DESCRIPTOR_VALUES.has(v)) bad.push(`${k} must be one of ${[...DESCRIPTOR_VALUES].join(" / ")}`);
71
+ }
72
+ if (bad.length) return { problem: `age-rating: ${bad.join("; ")}` };
73
+ return { attributes: { ...BASE, ...declared } };
74
+ }
75
+
6
76
  export async function run(config, client) {
7
- if (config.rating !== "4+") { console.error(yellow(`age-rating: only '4+' (all-NONE) implemented; config=${config.rating}`)); return false; }
77
+ const { attributes, problem } = resolveAttributes(config);
78
+ if (problem) { console.error(red(problem)); return false; }
8
79
  await client.findApp(config.bundleId);
80
+ // No allowLive: this PATCHes the age-rating declaration, and the live app-info's declaration is not
81
+ // ours to aim a write at. The refusal below used to be unreachable — appInfo() handed back the live
82
+ // record instead of null, so the write was planned against it and Apple's INVALID_STATE was the
83
+ // first anyone heard of it.
9
84
  const info = await client.appInfo();
10
- if (!info) { console.error(red("age-rating: no editable app info")); return false; }
85
+ if (!info) {
86
+ console.error(red("age-rating: no editable app info — refusing to write to the live record."));
87
+ console.error(" `vydanne prepare --apply` starts the next version, which makes app info editable again.");
88
+ return false;
89
+ }
11
90
  const { json } = await client.get(`/v1/appInfos/${info.id}/ageRatingDeclaration`);
12
91
  const id = json.data?.id;
13
92
  if (!id) { console.error(red("age-rating: no declaration")); return false; }
14
- const N = "NONE";
15
- // Apple's 2025 age-rating schema. A PATCH must include ALL required fields (a partial set 409s),
16
- // and `ageRatingOverride` (deprecated) cannot be sent alongside `ageRatingOverrideV2` — so we send
17
- // only V2. Content descriptors are enums (NONE); capability questions are booleans (false). All
18
- // benign here → 4+.
19
- const attributes = {
20
- advertising: false, alcoholTobaccoOrDrugUseOrReferences: N, contests: N, gambling: false,
21
- gamblingSimulated: N, gunsOrOtherWeapons: N, healthOrWellnessTopics: false, kidsAgeBand: null,
22
- lootBox: false, medicalOrTreatmentInformation: N, messagingAndChat: false, parentalControls: false,
23
- profanityOrCrudeHumor: N, ageAssurance: false, sexualContentGraphicAndNudity: N, sexualContentOrNudity: N,
24
- socialMedia: false, socialMediaAgeRestricted: false, horrorOrFearThemes: N, matureOrSuggestiveThemes: N,
25
- unrestrictedWebAccess: false, userGeneratedContent: false, violenceCartoonOrFantasy: N,
26
- violenceRealisticProlongedGraphicOrSadistic: N, violenceRealistic: N, ageRatingOverrideV2: N,
27
- koreaAgeRatingOverride: N,
28
- };
93
+
94
+ // What is actually being asserted, printed before it is sent. Apple computes the rating from these,
95
+ // so the declared non-defaults ARE the rating — worth seeing in the log of the run that set them.
96
+ const declared = Object.entries(attributes).filter(([k, v]) => v !== BASE[k] || (v !== false && v !== N && v !== null));
97
+ console.log(` declaring: ${declared.length ? declared.map(([k, v]) => `${k}=${v}`).join(", ") : "everything NONE (4+)"}`);
98
+
29
99
  const r = await client.patch(`/v1/ageRatingDeclarations/${id}`, { data: { type: "ageRatingDeclarations", id, attributes } });
30
100
  if (r.status >= 300) { console.error(red(`age-rating: ${r.status}: ${JSON.stringify(r.json).slice(0, 200)}`)); return false; }
31
- console.log(client.dryRun ? yellow("age rating WOULD be set -> 4+") : green("age rating set -> 4+"));
101
+ // Apple decides the band from the descriptors; naming config.rating here reports what was ASKED for,
102
+ // which is the only thing this command controls.
103
+ const label = config.ageRating ? `${config.rating} (from the declared descriptors)` : "4+";
104
+ console.log(client.dryRun ? yellow(`age rating WOULD be set -> ${label}`) : green(`age rating set -> ${label}`));
32
105
  return true;
33
106
  }