vydanne 0.5.0 → 0.7.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
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: vydanne
3
- description: Prepare an App Store Connect / Google Play submission — write AND push the localized store listing. Crafts the ASO copy (app-store name, subtitle, the 100-char keyword field, description, promo text), then fills the listing + screenshots + previews, age rating, review contact, accessibility & App Privacy labels, IAP fields, and export-compliance docs; verifies with a preflight gate and diffs local-vs-live. Native Node (ES256 JWT + fetch, no fastlane/Ruby). One vydanne.config.mjs per app. Use when writing or shipping any App Store / Play listing. Never submits — a human attaches the signed build and hits Submit.
3
+ description: Prepare an App Store Connect / Google Play submission — write AND push the localized store listing. Crafts the ASO copy (app-store name, subtitle, the 100-char keyword field, description, promo text), then fills the listing + screenshots + previews, age rating, review contact, accessibility & App Privacy labels, IAP fields, and export-compliance docs; verifies with a preflight gate and diffs local-vs-live. Native Node (ES256 JWT + fetch, no fastlane/Ruby). One vydanne.config.mjs per app. Use when writing or shipping any App Store / Play listing. Uploads builds to testers (TestFlight internal / Play closed track) and points the prepared version at them — but never submits for review and never ships to the public.
4
4
  ---
5
5
 
6
6
  # vydanne
@@ -11,10 +11,18 @@ 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.
16
-
17
- **Never submits.** A human attaches the signed build and presses Submit. Don't try to work around this.
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.
20
+
21
+ **Never submits for review; never ships to the public.** It *does* upload builds — `prerelease` sends
22
+ the `.ipa` to TestFlight (internal groups) or the `.aab` to a Play closed track, and points the version
23
+ being prepared at it. What stays human: pressing Submit, Play `production`, and external TestFlight
24
+ (which needs Beta App Review). Those are refusals rather than flags, so there is no argument
25
+ combination that reaches the public. Don't try to work around that.
18
26
 
19
27
  ## Setup
20
28
 
@@ -47,8 +55,30 @@ which user file was used, and whether the `.p8` is on disk.
47
55
 
48
56
  **Config fields:** `bundleId` · `primaryLocale` (the fallback — must be populated) · `asc` (optional
49
57
  `{profile}` — selection only, never secrets) · `platforms` (iOS and macOS are SEPARATE) · `uiLocales`
50
- (auto-mapped to ASC codes) · `metadataDir` · `rating` · `privacy` · `iaps` · `previews` · `export` ·
51
- `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.
52
82
 
53
83
  For a non-technical user asking how to set this up from scratch, walk them through
54
84
  **`GETTING_STARTED.md`** (accounts → API key → config → folders → push) rather than improvising.
@@ -90,17 +120,38 @@ before the **first underscore** selects the device slot; files upload in sorted
90
120
  | `watch_` | `APP_WATCH_ULTRA` |
91
121
  | `macos_` | `APP_DESKTOP` (in `screenshots-macos/`) |
92
122
 
93
- An unknown prefix is silently ignored. A set that **already has screenshots is skipped**, never
94
- 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**.
95
126
 
96
127
  **Play listing text** — `<google.metadataDir>/<PLAY-locale>/{title,short_description,full_description}.txt`
97
128
  (30 / 80 / 4000). Play uses its **own** codes (`de-DE`, `zh-CN`, `iw-IL`, `ar`, `be`) — *not* Apple's
98
129
  `zh-Hans`/`he`/`ar-SA`.
99
130
 
100
- **Play images** — hardcoded source paths (zdymak's output), each pushed only when the file exists, so a
101
- missing local set never deletes the live one: `brand/icons/play/icon-512.png` (512²) ·
102
- `marketing/out/play-feature-graphic.png` (1024×500) · `marketing/out/play-phone-plain/` ·
103
- `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.
104
155
 
105
156
  ## A. Writing the listing (the ASO craft — the durable value)
106
157
 
@@ -161,8 +212,65 @@ who it's for → honest close. Keep it scannable; lead each bullet with the payo
161
212
  `fill` (metadata + screenshots, native PATCH/chunked upload — works even at READY_FOR_REVIEW) ·
162
213
  `previews` (App Preview videos) · `age-rating` · `review-contact` · `accessibility` (draft; publish once
163
214
  live) · `privacy` (prints answers for the UI — the API can't reach Apple's iris host) · `iap` (validate +
164
- RGB flatten) · `compliance` (US self-classification PDF) · `diff` (what differs vs live) · `preflight`
165
- (completeness gate) · `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*.
229
+
230
+ **Cross-store lint.** `preflight` and `fill` refuse listing text that names the other mobile platform
231
+ — App Review 2.3.10 for Apple, the Store Listing and Promotion policy for Google — scanning every
232
+ locale and field of the LOCAL metadata before upload, including localized spellings of Android/Apple.
233
+ A store's own platform is never flagged, nor the bare word "Play". Ambiguous words warn instead of
234
+ blocking; `allowCrossStoreTerms: [...]` in the config silences a specific one, and
235
+ `VYDANNE_ALLOW_CROSS_STORE=1` overrides a single run. This is a rejection that surfaces days later in
236
+ one locale out of twenty, so it is checked where it is free to fix.
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.
166
274
 
167
275
  `prerelease` uploads the BUILD. On Apple it validates and uploads the `.ipa` to **TestFlight** via
168
276
  `xcrun altool` — the one command that shells out, because the ASC REST API has never carried a binary,
@@ -179,25 +287,49 @@ edit transaction. `production` is REFUSED — not flag-gated — so no argument
179
287
  public; promoting the tested build stays a human's job, mirroring the Apple side never submitting. Track
180
288
  comes from `google.track` / `VYDANNE_TRACK`, default `internal`; the bundle from `google.aab` /
181
289
  `VYDANNE_AAB` (a directory takes its newest `.aab`). **For a PAID app use `internal`** — it's the only
182
- track where testers install without buying. Notes follow supply's layout:
183
- `<google.metadataDir>/<play-locale>/changelogs/<versionCode>.txt`, falling back to `default.txt`, capped
184
- at Play's 500 chars. DRY by default like `fill --store google`; `VYDANNE_COMMIT=1` publishes. The
185
- versionCode comes from the bundle itself, 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.
186
298
 
187
299
  `--store google` routes `inspect` · `diff` · `preflight` · `fill` · `prerelease` to the Play Developer **Edits** API
188
300
  (OAuth2 service account; **scoped to the config's `packageName`** — a shared key can't touch another app).
189
- `fill --store google` is **DRY by default**; `VYDANNE_COMMIT=1` commits. The AAB binary and the
190
- (YouTube-URL) promo video stay outside vydanne.
301
+ The AAB binary and the (YouTube-URL) promo video stay outside vydanne.
302
+
303
+ ## `--apply` — writes are opt-in
304
+
305
+ **Every store-mutating command is a DRY RUN without `--apply`**: `prepare` · `push` · `fill` ·
306
+ `previews` · `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.
309
+
310
+ Never reach for `--apply` to "check whether it works" — the dry run IS the check, and it walks the whole
311
+ plan rather than stopping at the first locale. Its closing count is what you compare against `diff`.
312
+
313
+ Enforcement differs per store, on purpose: **Play** builds the Edit and validates it against Google for
314
+ real, then discards it (nothing is live until commit). **Apple** has no transaction, so the gate is at the
315
+ HTTP layer in `src/client.mjs` — no `POST`/`PATCH`/`PUT`/`DELETE` leaves the process, and each is recorded
316
+ in `client.planned`. `prerelease` needs its own guard because the `altool` binary upload does not go
317
+ through that client. **A new command that touches the store must be marked `writes: true` in
318
+ `src/registry.mjs`** — that flag is the whole opt-in, not a label.
191
319
 
192
320
  **Env toggles:** `VYDANNE_CONFIG` · `VYDANNE_SKIP_METADATA` / `VYDANNE_SKIP_SCREENSHOTS` (fill) ·
193
- `VYDANNE_COMMIT` (Play fill) · `VYDANNE_REPLACE` (previews) · `VYDANNE_FLATTEN=<png>` (iap) ·
194
- `VYDANNE_A11Y_PUBLISH` (accessibility).
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).
195
325
 
196
326
  ## Flow
197
327
 
198
- config → **write the English master listing (ASO, research-grounded)** → `preflight` (char limits) → fan
199
- out one copywriter agent per locale → media from zdymak → `fill` + `previews` + declarations → `diff`
200
- (dry-run) → `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**.
201
333
 
202
334
  ## Gotchas it encodes (don't re-derive)
203
335
 
package/bin/vydanne.mjs CHANGED
@@ -5,6 +5,7 @@ import path from "node:path";
5
5
  import { loadConfig } from "../src/config.mjs";
6
6
  import { Client } from "../src/client.mjs";
7
7
  import { COMMANDS, PLAY_COMMANDS } from "../src/registry.mjs";
8
+ import { yellow } from "../src/util.mjs";
8
9
 
9
10
  const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url))).version;
10
11
 
@@ -14,6 +15,23 @@ const i = argv.indexOf("--config");
14
15
  const cfgPath = i >= 0 ? argv[i + 1] : undefined;
15
16
  const si = argv.indexOf("--store");
16
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
+ }
26
+
27
+ // SAFE BY DEFAULT. Every store-mutating command is a dry run unless `--apply` is passed. The Play half
28
+ // always worked this way (VYDANNE_COMMIT=1, enforceable because an Edit can be discarded); the Apple half
29
+ // did not, and wrote the moment it was invoked — including from a mistyped `vydanne fill --help`, which
30
+ // is not a hypothetical. One flag now means the same thing on both stores.
31
+ //
32
+ // VYDANNE_COMMIT=1 stays as an alias so the existing `play:internal` / `play:closed` scripts in the games
33
+ // keep working unchanged; `--apply` is what the docs teach.
34
+ const apply = argv.includes("--apply") || process.env.VYDANNE_COMMIT === "1";
17
35
 
18
36
  try {
19
37
  if (["version", "-v", "--version"].includes(cmd)) {
@@ -47,23 +65,41 @@ try {
47
65
  console.log(`supported (${Object.keys(r.supported).length}):`);
48
66
  for (const [ui, asc] of Object.entries(r.supported)) console.log(` ${ui} -> ${asc}`);
49
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`);
50
71
  } else if (store === "google") {
51
72
  const cfg = await loadConfig(cfgPath);
52
73
  if (!cfg.google) throw new Error("vydanne: no `google` block in config — add packageName + a service-account key");
53
74
  if (!PLAY_COMMANDS[cmd]) throw new Error(`vydanne: '${cmd}' isn't available for --store google (try: ${Object.keys(PLAY_COMMANDS).join(", ")})`);
54
75
  if (!cfg.google.serviceAccountKey) throw new Error("vydanne: set PLAY_JSON_KEY_FILE (or google.serviceAccountKey) to the Play service-account JSON");
55
76
  const { PlayClient } = await import("../src/play/client.mjs");
56
- const client = await PlayClient.create({ keyPath: cfg.google.serviceAccountKey, packageName: cfg.google.packageName });
57
- const { run } = await import(`../src/play/commands/${PLAY_COMMANDS[cmd].mod}.mjs`);
77
+ const spec = PLAY_COMMANDS[cmd];
78
+ const dryRun = Boolean(spec.writes) && !apply;
79
+ // Play needs no request-level gate: every mutation happens inside an Edit, and an Edit that is never
80
+ // committed changes nothing. So a dry run here VALIDATES for real against Google, then discards.
81
+ const client = await PlayClient.create({ keyPath: cfg.google.serviceAccountKey, packageName: cfg.google.packageName, dryRun });
82
+ if (dryRun) console.log(yellow(`DRY RUN — '${cmd} --store google' validates against Play and discards the edit. Add --apply to commit.`));
83
+ const { run } = await import(`../src/play/commands/${spec.mod}.mjs`);
58
84
  const ok = await run(cfg, client);
59
85
  if (ok === false) process.exit(1);
60
86
  } else if (COMMANDS[cmd]) {
61
87
  const cfg = await loadConfig(cfgPath);
62
88
  const spec = COMMANDS[cmd];
63
89
  const { run } = await import(`../src/commands/${spec.mod}.mjs`);
64
- const client = spec.client ? new Client({ keyId: cfg.keyId, issuerId: cfg.issuerId }) : null;
90
+ const dryRun = Boolean(spec.writes) && !apply;
91
+ const client = spec.client ? new Client({ keyId: cfg.keyId, issuerId: cfg.issuerId, dryRun }) : null;
92
+ if (dryRun) console.log(yellow(`DRY RUN — '${cmd}' will not change App Store Connect. Add --apply to write.`));
65
93
  // altool authenticates on its own rather than through our JWT, so it needs the raw ids.
66
94
  const ok = await run(cfg, client, spec.credentials ? { keyId: cfg.keyId, issuerId: cfg.issuerId } : undefined);
95
+ // The count is the point: "nothing happened" is not the same as "nothing would happen", and only the
96
+ // second one means the local state already matches the store.
97
+ if (dryRun && client) {
98
+ const n = client.planned.length;
99
+ console.log(yellow(n
100
+ ? `DRY RUN — ${n} store write(s) withheld. Re-run with --apply to perform them.`
101
+ : "DRY RUN — nothing to write; the store already matches local."));
102
+ }
67
103
  if (ok === false) process.exit(1);
68
104
  } else {
69
105
  console.error(usage());
@@ -75,26 +111,44 @@ try {
75
111
  }
76
112
 
77
113
  function usage() {
78
- return `vydanne ${VERSION} — App Store Connect submission prep (companion to zdymak). Never submits.
79
- usage: vydanne <command> [--config vydanne.config.mjs]
80
- fill metadata + screenshots + previews (native; iOS & macOS separate)
81
- age-rating set the age rating (AppInfo declaration)
82
- review-contact App Review contact from the gitignored files
83
- accessibility Accessibility Nutrition Labels (draft; VYDANNE_A11Y_PUBLISH=1 to publish once live)
114
+ return `vydanne ${VERSION} — App Store Connect + Play prep (companion to zdymak). Ships builds to
115
+ testers; never submits for review.
116
+ usage: vydanne <command> [--apply] [--config vydanne.config.mjs]
117
+
118
+ --apply PERFORM the writes. Without it every store-mutating command below (marked ✎) runs
119
+ as a DRY RUN: it reads the store, reports exactly what it would change, and sends
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.
130
+ ✎ fill metadata + screenshots + previews (native; iOS & macOS separate)
131
+ ✎ age-rating set the age rating (AppInfo declaration)
132
+ ✎ review-contact App Review contact from the gitignored files
133
+ ✎ accessibility Accessibility Nutrition Labels (draft; VYDANNE_A11Y_PUBLISH=1 to publish once live)
84
134
  privacy write the record + print the ASC-UI answers (API can't reach iris)
85
- previews upload App Preview videos (native chunked upload)
135
+ ✎ previews upload App Preview videos (native chunked upload)
86
136
  iap validate IAP fields; VYDANNE_FLATTEN=<png> flattens a screenshot to RGB
87
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.
88
140
  inspect read-only ASC state
89
141
  diff show what differs between local (metadata/screenshots/previews) and ASC
90
142
  preflight verify submission-completeness (the gotcha checker)
91
- prerelease upload the build for testers — .ipa to TestFlight (internal groups only),
143
+ ✎ prerelease upload the build for testers — .ipa to TestFlight (internal groups only),
92
144
  or --store google: the .aab to a closed track. Refuses production/review.
93
145
  locales UI -> ASC locale mapping + unsupported
94
146
  auth which credentials resolved, and from where (masked) — run this on a 401
95
147
  credentials: env > .env cascade (.env, .env.<mode>, .env.local, .env.<mode>.local) > user config
96
148
  (\$VYDANNE_CONFIG_HOME, %APPDATA%\\vydanne or \$XDG_CONFIG_HOME/vydanne, ~/.appstoreconnect).
97
149
  NEVER the committed vydanne.config.mjs — run \`vydanne auth\` to see what resolved.
98
- toggles: VYDANNE_SKIP_METADATA / VYDANNE_SKIP_SCREENSHOTS (fill), VYDANNE_A11Y_PUBLISH (accessibility),
99
- VYDANNE_PROFILE (named profile), VYDANNE_ENV (.env mode)`;
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),
152
+ VYDANNE_PROFILE (named profile), VYDANNE_ENV (.env mode),
153
+ VYDANNE_COMMIT=1 (legacy alias for --apply — prefer the flag)`;
100
154
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "vydanne",
3
- "version": "0.5.0",
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, a diff of local-vs-store, and a preflight verifier that encodes the store gotchas. iOS/macOS via the ASC REST API; Android via the Play Developer Edits API (--store google).",
3
+ "version": "0.7.0",
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",
7
7
  "google-play",
@@ -51,7 +51,8 @@
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
+ "prepublishOnly": "npm run check:docs && npm run check:types && npm run check:config",
55
56
  "release:patch": "node scripts/release.mjs patch",
56
57
  "release:minor": "node scripts/release.mjs minor",
57
58
  "release:major": "node scripts/release.mjs major"
package/src/client.mjs CHANGED
@@ -1,24 +1,36 @@
1
1
  import { makeToken } from "./jwt.mjs";
2
+ import { yellow } from "./util.mjs";
2
3
 
3
4
  const API = "https://api.appstoreconnect.apple.com";
4
5
  const IRIS = "https://appstoreconnect.apple.com/iris";
5
- 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"];
6
9
  // READY_FOR_DISTRIBUTION is Apple's newer name for the LIVE app-info (they're migrating the
7
10
  // state vocabulary; versions still report appStoreState=READY_FOR_SALE). Without it here,
8
11
  // appInfo() treats the live record as editable and every name/subtitle PATCH comes back
9
12
  // ENTITY_ERROR.ATTRIBUTE.INVALID.INVALID_STATE — which fails the whole locale in `fill`,
10
13
  // release notes included, on any app that already has a version on sale.
11
- 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"];
15
+
16
+ // Anything that is not a read. ASC has no transaction to roll back — unlike Play, where an edit can be
17
+ // discarded — so for Apple the only safe place to stand between a command and a live listing is here.
18
+ const MUTATING = new Set(["POST", "PATCH", "PUT", "DELETE"]);
12
19
 
13
20
  // Thin ASC REST client. Encodes the gotchas: `iris` host (App Privacy 401s the JWT), version + app-info
14
21
  // fetched from the FULL list (get_edit filters out READY_FOR_REVIEW), and individual localization reads
15
22
  // (list endpoints return sparse/empty text).
16
23
  export class Client {
17
- constructor({ keyId, issuerId }) {
24
+ constructor({ keyId, issuerId, dryRun = false }) {
18
25
  this.token = makeToken({ keyId, issuerId });
26
+ /** No mutating request leaves this process. Set by bin/ for a write command without `--apply`. */
27
+ this.dryRun = dryRun;
28
+ /** What a real run WOULD have sent, in order — the dry-run report, and the count bin/ prints. */
29
+ this.planned = [];
19
30
  }
20
31
 
21
32
  async req(method, urlPath, { iris = false, body, rawHeaders, rawBody } = {}) {
33
+ if (this.dryRun && MUTATING.has(method)) return this.#plan(method, urlPath, body);
22
34
  const headers = { Authorization: `Bearer ${this.token}` };
23
35
  if (body) headers["Content-Type"] = "application/json";
24
36
  Object.assign(headers, rawHeaders || {});
@@ -31,6 +43,31 @@ export class Client {
31
43
  return { status: res.status, json, text };
32
44
  }
33
45
 
46
+ /**
47
+ * Record a mutation instead of sending it, and hand back a response shaped like the one Apple would
48
+ * have returned.
49
+ *
50
+ * The shape matters as much as the refusal. A dry run that returned `null` here would crash the first
51
+ * caller that reads `.json.data.id` — and the operator would see one locale out of twenty, which is
52
+ * exactly the report they cannot act on. So a synthesised `data` carries the id the caller needs to
53
+ * keep going, and the run walks the WHOLE plan: every locale, every screenshot set, every field.
54
+ *
55
+ * The id is deliberately `dry-run-<n>` rather than a plausible-looking one — if it ever escapes into a
56
+ * URL, the request 404s loudly instead of touching some real record.
57
+ */
58
+ #plan(method, urlPath, body) {
59
+ const attributes = body?.data?.attributes || {};
60
+ const fields = Object.keys(attributes);
61
+ this.planned.push({ method, path: urlPath, attributes });
62
+ console.log(yellow(` would ${method} ${urlPath}${fields.length ? ` — ${fields.join(", ")}` : ""}`));
63
+ return {
64
+ status: method === "DELETE" ? 204 : 200,
65
+ json: { data: { id: body?.data?.id || `dry-run-${this.planned.length}`, type: body?.data?.type, attributes } },
66
+ text: "",
67
+ dryRun: true,
68
+ };
69
+ }
70
+
34
71
  get(p, opts) { return this.req("GET", p, opts); }
35
72
  post(p, body) { return this.req("POST", p, { body }); }
36
73
  patch(p, body) { return this.req("PATCH", p, { body }); }
@@ -44,16 +81,50 @@ export class Client {
44
81
  return app;
45
82
  }
46
83
 
47
- 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 } = {}) {
48
101
  const { json } = await this.get(`/v1/apps/${this.appId}/appStoreVersions?filter[platform]=${platform}&limit=10`);
49
102
  const data = json.data || [];
50
- 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;
51
106
  }
52
107
 
53
- 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 } = {}) {
54
123
  const { json } = await this.get(`/v1/apps/${this.appId}/appInfos?limit=10`);
55
124
  const data = json.data || [];
56
- 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;
57
128
  }
58
129
 
59
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")