vydanne 0.4.2 → 0.6.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/README.md CHANGED
@@ -7,7 +7,9 @@ description, keywords, screenshots… once per language, twice per store, again
7
7
  box and the store quietly shows a blank page to half the world. Get a folder name wrong and the whole
8
8
  upload fails.
9
9
 
10
- vydanne does that typing for you, then **checks your work before Apple or Google does**.
10
+ vydanne does that typing for you, then **checks your work before Apple or Google does** — and puts
11
+ your build in front of testers on both stores. It stops at the one step that should stay a human's:
12
+ it never submits for review, and never ships to the public.
11
13
 
12
14
  > *выданне* (Belarusian) — "publishing".
13
15
 
@@ -155,26 +157,58 @@ fastlane/metadata/en-US/keywords.txt
155
157
  ```sh
156
158
  npx vydanne preflight # anything missing or too long?
157
159
  npx vydanne diff # what exactly would change?
158
- npx vydanne fill # do it
160
+ npx vydanne fill # DRY RUN — prints every write it would make
161
+ npx vydanne fill --apply # do it
159
162
  ```
160
163
 
161
164
  Run vydanne **from your project folder** — it finds everything relative to where you are.
162
165
 
163
166
  <br>
164
167
 
168
+ ## `--apply`, or nothing happens
169
+
170
+ **Every command that can change a store is a dry run unless you pass `--apply`.** It reads the store,
171
+ prints each write it would make, and sends nothing:
172
+
173
+ ```
174
+ DRY RUN — 'fill' will not change App Store Connect. Add --apply to write.
175
+ would PATCH /v1/appStoreVersionLocalizations/ad2f… — description, keywords, whatsNew
176
+
177
+ DRY RUN — 40 store write(s) withheld. Re-run with --apply to perform them.
178
+ ```
179
+
180
+ The commands this applies to are marked `✎` in `vydanne help`: `fill`, `previews`, `age-rating`,
181
+ `review-contact`, `accessibility`, `prerelease`. Everything else only reads, and ignores the flag.
182
+
183
+ Two details worth knowing:
184
+
185
+ - **A dry run walks the whole plan.** It does not stop at the first locale — the count at the end is the
186
+ number to compare against `diff`. "Nothing to write" and "nothing happened" are different sentences, and
187
+ only the first one means your local files already match the store.
188
+ - **The two stores enforce it differently, deliberately.** Play builds the Edit and *validates it against
189
+ Google* for real, then discards it — so a dry run catches everything a commit would have caught. Apple
190
+ has no transaction to roll back, so there the block is at the HTTP layer: no `POST`/`PATCH`/`PUT`/
191
+ `DELETE` leaves the process at all. `prerelease` also refuses the `altool` upload, after validating the
192
+ archive.
193
+
194
+ > Upgrading from ≤ 0.5? The Apple half used to write immediately — `vydanne fill` now needs `--apply`.
195
+ > `VYDANNE_COMMIT=1` still works as an alias so existing Play scripts keep running, but prefer the flag.
196
+
197
+ <br>
198
+
165
199
  ## What each command does
166
200
 
167
201
  | Command | In plain English |
168
202
  |---|---|
169
- | `preflight` | **Run this first.** Checks the listing is complete and nothing is over a character limit. Green means submittable. |
203
+ | `preflight` | **Run this first.** Checks the listing is complete, nothing is over a character limit, and no locale mentions the other app store. Green means submittable. |
170
204
  | `diff` | Shows exactly what's different between your files and what's live. Nothing is changed — a safe preview. |
171
- | `fill` | Uploads your listing text and screenshots. Handles iPhone, iPad and Mac. |
205
+ | `fill` | Uploads your listing text and screenshots. Handles iPhone, iPad and Mac. Refuses to upload text that names the other mobile platform. |
172
206
  | `previews` | Uploads App Preview videos. |
173
207
  | `inspect` | Shows the app's current state in the store. Read-only. |
174
208
  | `locales` | Lists your languages and Apple's code for each — and warns about any language the App Store doesn't offer. |
175
209
  | `age-rating` | Sets the age rating. |
176
210
  | `review-contact` | Fills in the App Review contact details (who Apple calls if there's a problem). |
177
- | `accessibility` | Saves Accessibility Nutrition Labels. Stays a draft until your app is live. |
211
+ | `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. |
178
212
  | `privacy` | Prints the privacy answers to paste into Apple's website (Apple's privacy section has no API). |
179
213
  | `iap` | Checks your in-app purchase text fits, and can strip transparency from an image. |
180
214
  | `compliance` | Generates the US encryption self-classification PDF that Apple asks for. |
@@ -182,6 +216,9 @@ Run vydanne **from your project folder** — it finds everything relative to whe
182
216
 
183
217
  For **Google Play**, add `--store google` to `inspect`, `diff`, `preflight`, `fill`, or `prerelease`.
184
218
 
219
+ `fill`, `previews`, `age-rating`, `review-contact`, `accessibility` and `prerelease` change the store, so
220
+ they need [`--apply`](#--apply-or-nothing-happens); without it they report and exit.
221
+
185
222
  <br>
186
223
 
187
224
  ## Google Play
@@ -191,19 +228,60 @@ Add a `google` block to your config and point `PLAY_JSON_KEY_FILE` at a service-
191
228
 
192
229
  ```sh
193
230
  npx vydanne preflight --store google
194
- npx vydanne fill --store google # dry run — shows what would change
195
- VYDANNE_COMMIT=1 npx vydanne fill --store google # actually do it
196
- # Windows PowerShell: $env:VYDANNE_COMMIT = "1"; npx vydanne fill --store google
231
+ npx vydanne fill --store google # dry run — shows what would change
232
+ npx vydanne fill --store google --apply # actually do it
233
+ ```
234
+
235
+ ### `prerelease` — the build, to testers
236
+
237
+ `fill` writes the *listing*; `prerelease` uploads the **binary** — to TestFlight on Apple, or to a
238
+ closed testing track on Play. Neither submits anything for review.
239
+
240
+ **Apple — TestFlight**
241
+
242
+ ```sh
243
+ npx vydanne prerelease # validate, upload, wait for processing
244
+ ```
245
+
246
+ ```js
247
+ ios: {
248
+ ipa: "./dist", // a file, or a directory whose NEWEST .ipa is taken
249
+ testFlightGroup: "Internal", // optional; INTERNAL groups only
250
+ }
251
+ ```
252
+
253
+ The App Store Connect REST API has never accepted a binary, so this is the one command that shells
254
+ out — to `xcrun altool`, which ships with Xcode and authenticates from the same
255
+ `~/.appstoreconnect/private_keys` key `vydanne auth` reports. That makes it **macOS-only**, which it
256
+ checks up front. The archive is validated before it is uploaded, so the common refusals (bad
257
+ entitlements, missing icons, a version Apple already holds) cost seconds rather than a full transfer.
258
+
259
+ Build numbers come from the archive's own `CFBundleVersion` — re-uploading one Apple already holds
260
+ fails loudly instead of quietly replacing a binary.
261
+
262
+ It also **points the version you are preparing at the build it just uploaded**, which is the loop
263
+ this command exists for:
264
+
265
+ ```sh
266
+ npx vydanne prerelease # version 1.1 -> build 66, off to testers
267
+ # …find something, fix it, re-archive with a new build number…
268
+ npx vydanne prerelease # version 1.1: build 66 -> build 67
197
269
  ```
198
270
 
199
- ### `prerelease` the build, to a testing track
271
+ The version→build relationship holds exactly one build, so re-running re-points it and there is
272
+ nothing to clean up. A version that is no longer editable — `IN_REVIEW`, `READY_FOR_SALE` — is left
273
+ alone and said so, because re-pointing it would mean withdrawing that submission, and that is a
274
+ decision with reviewer-facing consequences.
275
+
276
+ **External TestFlight groups are refused.** Distributing to them requires Beta App Review, which is a
277
+ submission by another name; internal groups are the exact parallel of Play's `internal` track, and on
278
+ a paid app they are the testers who install without buying it.
200
279
 
201
- `fill` writes the *listing*; `prerelease` uploads the **binary** to a **closed testing track** with
202
- release notes, all inside one edit transaction:
280
+ **Google Play a closed track**
203
281
 
204
282
  ```sh
205
- npx vydanne prerelease --store google # dry run
206
- VYDANNE_COMMIT=1 npx vydanne prerelease --store google # publish to the track
283
+ npx vydanne prerelease --store google # dry run
284
+ npx vydanne prerelease --store google --apply # publish to the track
207
285
  ```
208
286
 
209
287
  ```js
@@ -227,7 +305,7 @@ Play's 500-char cap with a warning. The versionCode comes from the bundle's own
227
305
  numbering stays with the build and re-uploading a used code fails loudly instead of silently replacing a
228
306
  binary. Overrides: `VYDANNE_AAB`, `VYDANNE_TRACK`, `VYDANNE_RELEASE_NAME`.
229
307
 
230
- **Play is dry by default on purpose.** Nothing goes live until you add `VYDANNE_COMMIT=1`, so a
308
+ **Play is dry by default on purpose.** Nothing goes live until you add `--apply`, so a
231
309
  half-finished folder can never overwrite a good listing. Play also uses its **own** language codes
232
310
  (`zh-CN`, `iw-IL`) which are *not* Apple's — `vydanne locales` and the
233
311
  [layout guide](GETTING_STARTED.md#6-put-your-text-and-images-where-vydanne-looks) keep them straight.
@@ -261,6 +339,7 @@ don't have to learn them the hard way.
261
339
  | Screenshots with transparency get rejected | Converts them to RGB |
262
340
  | Once a version is *Ready for Review*, most tools can no longer edit it | Uses a method that still works |
263
341
  | Character limits (30 / 30 / 100 / 170; purchases 30 / 45) | Checked before upload, not after rejection |
342
+ | One translation says "also on Google Play" → rejected under guideline 2.3.10 | Every locale is scanned before upload; `preflight` and `fill` both refuse |
264
343
  | Apple's privacy section can't be reached by any API key | Prints the exact answers to paste in |
265
344
  | Accessibility labels can't publish before launch | Saved as a draft automatically |
266
345
  | In-app purchases need **two** different images, easily confused | Labels both slots |
@@ -271,8 +350,12 @@ don't have to learn them the hard way.
271
350
 
272
351
  ## What vydanne will never do
273
352
 
274
- - **It never submits your app.** A human attaches the build and presses Submit. That's on purpose.
275
- - It doesn't build, sign, or upload your app binary.
353
+ - **It never submits for review, and never ships to the public.** It *will* put a build in front of
354
+ your testers TestFlight internal, or a Play closed track — and point the version you are
355
+ preparing at it. Pressing Submit, promoting to Play production, and distributing to external
356
+ TestFlight (which needs Beta App Review) all stay yours. Those are refusals, not flags: there is
357
+ no argument combination that reaches the public.
358
+ - It doesn't build or sign your binary. It uploads the `.ipa` / `.aab` you already produced.
276
359
  - It doesn't create the app record — make that in App Store Connect / Play Console first.
277
360
 
278
361
  <br>
@@ -293,3 +376,56 @@ goes through the Google Play Developer **Edits** API.
293
376
  Releasing a new version: [RELEASING.md](RELEASING.md).
294
377
 
295
378
  MIT.
379
+
380
+
381
+ ### One store never mentions the other
382
+
383
+ Both stores reject a listing that advertises the competing platform — Apple under App Review
384
+ guideline **2.3.10** ("no names, icons, or imagery of other mobile platforms"), Google under its
385
+ Store Listing and Promotion policy. It is an easy mistake to make and an expensive one to find: the
386
+ two listings come from the same source copy, so a single translator writing "auch für Android"
387
+ costs a review cycle, in one locale out of twenty, days later.
388
+
389
+ `preflight` and `fill` both scan the local metadata before anything is uploaded, per locale, per
390
+ field — store names, store URLs, and the other platform's device names, in Latin script and in the
391
+ localized forms (安卓, Андроид, アンドロイド, …). A store's OWN platform is never flagged: "Android"
392
+ belongs in a Play listing. Neither does the bare word "Play", which every game listing uses.
393
+
394
+ Ambiguous words ("apple" in a game about fruit) are reported as warnings and never block. If one
395
+ genuinely belongs in your copy:
396
+
397
+ ```js
398
+ allowCrossStoreTerms: ["Apple"],
399
+ ```
400
+
401
+ `VYDANNE_ALLOW_CROSS_STORE=1` overrides the whole check for one run.
402
+
403
+ ### Accessibility Nutrition Labels
404
+
405
+ Every other thing vydanne writes is a *fact* about your app. This one is a **claim about its
406
+ behaviour**, made to Apple — so the tool will not guess it for you.
407
+
408
+ ```js
409
+ accessibility: {
410
+ voiceover: true,
411
+ voiceControl: true,
412
+ largerText: true,
413
+ sufficientContrast: true,
414
+ darkInterface: true,
415
+ differentiateWithoutColorAlone: true,
416
+ reducedMotion: true,
417
+ captions: false,
418
+ audioDescriptions: false,
419
+ },
420
+ ```
421
+
422
+ Every feature is stated explicitly. An omission would read as a quiet "no", which is just as
423
+ unverified as a quiet "yes", so a partial block is rejected along with a missing one.
424
+
425
+ Earlier versions applied one hardcoded matrix to every app, which meant an app inherited claims
426
+ nobody had checked against it. At least one shipped app declared Larger Text support while its
427
+ board glyphs scaled twice and grew off the high-contrast disc behind them. If you are upgrading,
428
+ audit before you declare.
429
+
430
+ Apple's platform caveats are still applied automatically: Larger Text does not exist on macOS and
431
+ Voice Control does not exist on watchOS, so those are sent as false whatever you declare.
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
@@ -14,7 +14,11 @@ user needs screenshots or a preview video produced, that's zdymak's job, not vyd
14
14
  uploads files that already exist. zdymak's default output paths are exactly the paths vydanne reads for
15
15
  Play images (below), so the two line up with no glue.
16
16
 
17
- **Never submits.** A human attaches the signed build and presses Submit. Don't try to work around this.
17
+ **Never submits for review; never ships to the public.** It *does* upload builds `prerelease` sends
18
+ the `.ipa` to TestFlight (internal groups) or the `.aab` to a Play closed track, and points the version
19
+ being prepared at it. What stays human: pressing Submit, Play `production`, and external TestFlight
20
+ (which needs Beta App Review). Those are refusals rather than flags, so there is no argument
21
+ combination that reaches the public. Don't try to work around that.
18
22
 
19
23
  ## Setup
20
24
 
@@ -162,32 +166,65 @@ who it's for → honest close. Keep it scannable; lead each bullet with the payo
162
166
  `previews` (App Preview videos) · `age-rating` · `review-contact` · `accessibility` (draft; publish once
163
167
  live) · `privacy` (prints answers for the UI — the API can't reach Apple's iris host) · `iap` (validate +
164
168
  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`.
166
-
167
- `prerelease` (**Play only**) uploads an `.aab` to a **closed testing track** with release notes, inside one
169
+ (completeness gate + cross-store lint) · `inspect` · `auth` (what credentials resolved, and from where) · `locales` · `version`.
170
+
171
+ **Cross-store lint.** `preflight` and `fill` refuse listing text that names the other mobile platform
172
+ — App Review 2.3.10 for Apple, the Store Listing and Promotion policy for Google — scanning every
173
+ locale and field of the LOCAL metadata before upload, including localized spellings of Android/Apple.
174
+ A store's own platform is never flagged, nor the bare word "Play". Ambiguous words warn instead of
175
+ blocking; `allowCrossStoreTerms: [...]` in the config silences a specific one, and
176
+ `VYDANNE_ALLOW_CROSS_STORE=1` overrides a single run. This is a rejection that surfaces days later in
177
+ one locale out of twenty, so it is checked where it is free to fix.
178
+
179
+ `prerelease` uploads the BUILD. On Apple it validates and uploads the `.ipa` to **TestFlight** via
180
+ `xcrun altool` — the one command that shells out, because the ASC REST API has never carried a binary,
181
+ which also makes it macOS-only. `.ipa` comes from `ios.ipa` / `VYDANNE_IPA` (a directory takes its
182
+ newest), the build number from the archive's own `CFBundleVersion`, and `ios.testFlightGroup` may add
183
+ it to an **internal** group. External groups are REFUSED — they need Beta App Review, a submission by
184
+ another name — and App Store review is never submitted, mirroring the Play side refusing `production`.
185
+ It then points the version being prepared AT that build, so the fix-and-re-upload loop is one command:
186
+ re-running re-points (the relationship holds one build), while a version that is `IN_REVIEW` or
187
+ `READY_FOR_SALE` is left alone, since re-pointing it would mean withdrawing a submission.
188
+
189
+ With `--store google` it uploads an `.aab` to a **closed testing track** with release notes, inside one
168
190
  edit transaction. `production` is REFUSED — not flag-gated — so no argument combination ships to the
169
191
  public; promoting the tested build stays a human's job, mirroring the Apple side never submitting. Track
170
192
  comes from `google.track` / `VYDANNE_TRACK`, default `internal`; the bundle from `google.aab` /
171
193
  `VYDANNE_AAB` (a directory takes its newest `.aab`). **For a PAID app use `internal`** — it's the only
172
194
  track where testers install without buying. Notes follow supply's layout:
173
195
  `<google.metadataDir>/<play-locale>/changelogs/<versionCode>.txt`, falling back to `default.txt`, capped
174
- at Play's 500 chars. DRY by default like `fill --store google`; `VYDANNE_COMMIT=1` publishes. The
175
- versionCode comes from the bundle itself, so re-uploading one fails loudly instead of silently replacing.
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.
176
198
 
177
199
  `--store google` routes `inspect` · `diff` · `preflight` · `fill` · `prerelease` to the Play Developer **Edits** API
178
200
  (OAuth2 service account; **scoped to the config's `packageName`** — a shared key can't touch another app).
179
- `fill --store google` is **DRY by default**; `VYDANNE_COMMIT=1` commits. The AAB binary and the
180
- (YouTube-URL) promo video stay outside vydanne.
201
+ The AAB binary and the (YouTube-URL) promo video stay outside vydanne.
202
+
203
+ ## `--apply` — writes are opt-in
204
+
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.
208
+
209
+ Never reach for `--apply` to "check whether it works" — the dry run IS the check, and it walks the whole
210
+ plan rather than stopping at the first locale. Its closing count is what you compare against `diff`.
211
+
212
+ Enforcement differs per store, on purpose: **Play** builds the Edit and validates it against Google for
213
+ real, then discards it (nothing is live until commit). **Apple** has no transaction, so the gate is at the
214
+ HTTP layer in `src/client.mjs` — no `POST`/`PATCH`/`PUT`/`DELETE` leaves the process, and each is recorded
215
+ in `client.planned`. `prerelease` needs its own guard because the `altool` binary upload does not go
216
+ through that client. **A new command that touches the store must be marked `writes: true` in
217
+ `src/registry.mjs`** — that flag is the whole opt-in, not a label.
181
218
 
182
219
  **Env toggles:** `VYDANNE_CONFIG` · `VYDANNE_SKIP_METADATA` / `VYDANNE_SKIP_SCREENSHOTS` (fill) ·
183
- `VYDANNE_COMMIT` (Play fill) · `VYDANNE_REPLACE` (previews) · `VYDANNE_FLATTEN=<png>` (iap) ·
184
- `VYDANNE_A11Y_PUBLISH` (accessibility).
220
+ `VYDANNE_REPLACE` (previews) · `VYDANNE_FLATTEN=<png>` (iap) · `VYDANNE_A11Y_PUBLISH` (accessibility) ·
221
+ `VYDANNE_COMMIT=1` (legacy alias for `--apply`; prefer the flag).
185
222
 
186
223
  ## Flow
187
224
 
188
225
  config → **write the English master listing (ASO, research-grounded)** → `preflight` (char limits) → fan
189
- out one copywriter agent per locale → media from zdymak → `fill` + `previews` + declarations `diff`
190
- (dry-run) → `preflight` (must be green) → **a human submits**.
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**.
191
228
 
192
229
  ## Gotchas it encodes (don't re-derive)
193
230
 
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
 
@@ -15,6 +16,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";
17
18
 
19
+ // SAFE BY DEFAULT. Every store-mutating command is a dry run unless `--apply` is passed. The Play half
20
+ // always worked this way (VYDANNE_COMMIT=1, enforceable because an Edit can be discarded); the Apple half
21
+ // did not, and wrote the moment it was invoked — including from a mistyped `vydanne fill --help`, which
22
+ // is not a hypothetical. One flag now means the same thing on both stores.
23
+ //
24
+ // VYDANNE_COMMIT=1 stays as an alias so the existing `play:internal` / `play:closed` scripts in the games
25
+ // keep working unchanged; `--apply` is what the docs teach.
26
+ const apply = argv.includes("--apply") || process.env.VYDANNE_COMMIT === "1";
27
+
18
28
  try {
19
29
  if (["version", "-v", "--version"].includes(cmd)) {
20
30
  console.log(`vydanne ${VERSION}`);
@@ -53,16 +63,32 @@ try {
53
63
  if (!PLAY_COMMANDS[cmd]) throw new Error(`vydanne: '${cmd}' isn't available for --store google (try: ${Object.keys(PLAY_COMMANDS).join(", ")})`);
54
64
  if (!cfg.google.serviceAccountKey) throw new Error("vydanne: set PLAY_JSON_KEY_FILE (or google.serviceAccountKey) to the Play service-account JSON");
55
65
  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`);
66
+ const spec = PLAY_COMMANDS[cmd];
67
+ const dryRun = Boolean(spec.writes) && !apply;
68
+ // Play needs no request-level gate: every mutation happens inside an Edit, and an Edit that is never
69
+ // committed changes nothing. So a dry run here VALIDATES for real against Google, then discards.
70
+ const client = await PlayClient.create({ keyPath: cfg.google.serviceAccountKey, packageName: cfg.google.packageName, dryRun });
71
+ if (dryRun) console.log(yellow(`DRY RUN — '${cmd} --store google' validates against Play and discards the edit. Add --apply to commit.`));
72
+ const { run } = await import(`../src/play/commands/${spec.mod}.mjs`);
58
73
  const ok = await run(cfg, client);
59
74
  if (ok === false) process.exit(1);
60
75
  } else if (COMMANDS[cmd]) {
61
76
  const cfg = await loadConfig(cfgPath);
62
77
  const spec = COMMANDS[cmd];
63
78
  const { run } = await import(`../src/commands/${spec.mod}.mjs`);
64
- const client = spec.client ? new Client({ keyId: cfg.keyId, issuerId: cfg.issuerId }) : null;
65
- const ok = await run(cfg, client);
79
+ const dryRun = Boolean(spec.writes) && !apply;
80
+ const client = spec.client ? new Client({ keyId: cfg.keyId, issuerId: cfg.issuerId, dryRun }) : null;
81
+ if (dryRun) console.log(yellow(`DRY RUN — '${cmd}' will not change App Store Connect. Add --apply to write.`));
82
+ // altool authenticates on its own rather than through our JWT, so it needs the raw ids.
83
+ const ok = await run(cfg, client, spec.credentials ? { keyId: cfg.keyId, issuerId: cfg.issuerId } : undefined);
84
+ // The count is the point: "nothing happened" is not the same as "nothing would happen", and only the
85
+ // second one means the local state already matches the store.
86
+ if (dryRun && client) {
87
+ const n = client.planned.length;
88
+ console.log(yellow(n
89
+ ? `DRY RUN — ${n} store write(s) withheld. Re-run with --apply to perform them.`
90
+ : "DRY RUN — nothing to write; the store already matches local."));
91
+ }
66
92
  if (ok === false) process.exit(1);
67
93
  } else {
68
94
  console.error(usage());
@@ -74,25 +100,32 @@ try {
74
100
  }
75
101
 
76
102
  function usage() {
77
- return `vydanne ${VERSION} — App Store Connect submission prep (companion to zdymak). Never submits.
78
- usage: vydanne <command> [--config vydanne.config.mjs]
79
- fill metadata + screenshots + previews (native; iOS & macOS separate)
80
- age-rating set the age rating (AppInfo declaration)
81
- review-contact App Review contact from the gitignored files
82
- accessibility Accessibility Nutrition Labels (draft; VYDANNE_A11Y_PUBLISH=1 to publish once live)
103
+ return `vydanne ${VERSION} — App Store Connect + Play prep (companion to zdymak). Ships builds to
104
+ testers; never submits for review.
105
+ usage: vydanne <command> [--apply] [--config vydanne.config.mjs]
106
+
107
+ --apply PERFORM the writes. Without it every store-mutating command below (marked ✎) runs
108
+ as a DRY RUN: it reads the store, reports exactly what it would change, and sends
109
+ nothing. Read-only commands ignore the flag.
110
+ ✎ fill metadata + screenshots + previews (native; iOS & macOS separate)
111
+ ✎ age-rating set the age rating (AppInfo declaration)
112
+ ✎ review-contact App Review contact from the gitignored files
113
+ ✎ accessibility Accessibility Nutrition Labels (draft; VYDANNE_A11Y_PUBLISH=1 to publish once live)
83
114
  privacy write the record + print the ASC-UI answers (API can't reach iris)
84
- previews upload App Preview videos (native chunked upload)
115
+ previews upload App Preview videos (native chunked upload)
85
116
  iap validate IAP fields; VYDANNE_FLATTEN=<png> flattens a screenshot to RGB
86
117
  compliance generate the US encryption self-classification PDF
87
118
  inspect read-only ASC state
88
119
  diff show what differs between local (metadata/screenshots/previews) and ASC
89
120
  preflight verify submission-completeness (the gotcha checker)
90
- prerelease --store google: upload the .aab to a closed testing track (refuses production)
121
+ prerelease upload the build for testers .ipa to TestFlight (internal groups only),
122
+ or --store google: the .aab to a closed track. Refuses production/review.
91
123
  locales UI -> ASC locale mapping + unsupported
92
124
  auth which credentials resolved, and from where (masked) — run this on a 401
93
125
  credentials: env > .env cascade (.env, .env.<mode>, .env.local, .env.<mode>.local) > user config
94
126
  (\$VYDANNE_CONFIG_HOME, %APPDATA%\\vydanne or \$XDG_CONFIG_HOME/vydanne, ~/.appstoreconnect).
95
127
  NEVER the committed vydanne.config.mjs — run \`vydanne auth\` to see what resolved.
96
128
  toggles: VYDANNE_SKIP_METADATA / VYDANNE_SKIP_SCREENSHOTS (fill), VYDANNE_A11Y_PUBLISH (accessibility),
97
- VYDANNE_PROFILE (named profile), VYDANNE_ENV (.env mode)`;
129
+ VYDANNE_PROFILE (named profile), VYDANNE_ENV (.env mode),
130
+ VYDANNE_COMMIT=1 (legacy alias for --apply — prefer the flag)`;
98
131
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "vydanne",
3
- "version": "0.4.2",
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.6.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",
package/src/client.mjs CHANGED
@@ -1,4 +1,5 @@
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";
@@ -10,15 +11,24 @@ const DEAD_VERSION = ["READY_FOR_SALE", "REMOVED_FROM_SALE", "REPLACED_WITH_NEW_
10
11
  // release notes included, on any app that already has a version on sale.
11
12
  const DEAD_INFO = ["READY_FOR_SALE", "READY_FOR_DISTRIBUTION", "REPLACED_WITH_NEW_VERSION", "REMOVED_FROM_SALE"];
12
13
 
14
+ // Anything that is not a read. ASC has no transaction to roll back — unlike Play, where an edit can be
15
+ // discarded — so for Apple the only safe place to stand between a command and a live listing is here.
16
+ const MUTATING = new Set(["POST", "PATCH", "PUT", "DELETE"]);
17
+
13
18
  // Thin ASC REST client. Encodes the gotchas: `iris` host (App Privacy 401s the JWT), version + app-info
14
19
  // fetched from the FULL list (get_edit filters out READY_FOR_REVIEW), and individual localization reads
15
20
  // (list endpoints return sparse/empty text).
16
21
  export class Client {
17
- constructor({ keyId, issuerId }) {
22
+ constructor({ keyId, issuerId, dryRun = false }) {
18
23
  this.token = makeToken({ keyId, issuerId });
24
+ /** No mutating request leaves this process. Set by bin/ for a write command without `--apply`. */
25
+ this.dryRun = dryRun;
26
+ /** What a real run WOULD have sent, in order — the dry-run report, and the count bin/ prints. */
27
+ this.planned = [];
19
28
  }
20
29
 
21
30
  async req(method, urlPath, { iris = false, body, rawHeaders, rawBody } = {}) {
31
+ if (this.dryRun && MUTATING.has(method)) return this.#plan(method, urlPath, body);
22
32
  const headers = { Authorization: `Bearer ${this.token}` };
23
33
  if (body) headers["Content-Type"] = "application/json";
24
34
  Object.assign(headers, rawHeaders || {});
@@ -31,6 +41,31 @@ export class Client {
31
41
  return { status: res.status, json, text };
32
42
  }
33
43
 
44
+ /**
45
+ * Record a mutation instead of sending it, and hand back a response shaped like the one Apple would
46
+ * have returned.
47
+ *
48
+ * The shape matters as much as the refusal. A dry run that returned `null` here would crash the first
49
+ * caller that reads `.json.data.id` — and the operator would see one locale out of twenty, which is
50
+ * exactly the report they cannot act on. So a synthesised `data` carries the id the caller needs to
51
+ * keep going, and the run walks the WHOLE plan: every locale, every screenshot set, every field.
52
+ *
53
+ * The id is deliberately `dry-run-<n>` rather than a plausible-looking one — if it ever escapes into a
54
+ * URL, the request 404s loudly instead of touching some real record.
55
+ */
56
+ #plan(method, urlPath, body) {
57
+ const attributes = body?.data?.attributes || {};
58
+ const fields = Object.keys(attributes);
59
+ this.planned.push({ method, path: urlPath, attributes });
60
+ console.log(yellow(` would ${method} ${urlPath}${fields.length ? ` — ${fields.join(", ")}` : ""}`));
61
+ return {
62
+ status: method === "DELETE" ? 204 : 200,
63
+ json: { data: { id: body?.data?.id || `dry-run-${this.planned.length}`, type: body?.data?.type, attributes } },
64
+ text: "",
65
+ dryRun: true,
66
+ };
67
+ }
68
+
34
69
  get(p, opts) { return this.req("GET", p, opts); }
35
70
  post(p, body) { return this.req("POST", p, { body }); }
36
71
  patch(p, body) { return this.req("PATCH", p, { body }); }