vydanne 0.5.0 → 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,20 +157,52 @@ 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. |
@@ -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,9 +228,8 @@ 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
197
233
  ```
198
234
 
199
235
  ### `prerelease` — the build, to testers
@@ -244,8 +280,8 @@ a paid app they are the testers who install without buying it.
244
280
  **Google Play — a closed track**
245
281
 
246
282
  ```sh
247
- npx vydanne prerelease --store google # dry run
248
- 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
249
285
  ```
250
286
 
251
287
  ```js
@@ -269,7 +305,7 @@ Play's 500-char cap with a warning. The versionCode comes from the bundle's own
269
305
  numbering stays with the build and re-uploading a used code fails loudly instead of silently replacing a
270
306
  binary. Overrides: `VYDANNE_AAB`, `VYDANNE_TRACK`, `VYDANNE_RELEASE_NAME`.
271
307
 
272
- **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
273
309
  half-finished folder can never overwrite a good listing. Play also uses its **own** language codes
274
310
  (`zh-CN`, `iw-IL`) which are *not* Apple's — `vydanne locales` and the
275
311
  [layout guide](GETTING_STARTED.md#6-put-your-text-and-images-where-vydanne-looks) keep them straight.
@@ -303,6 +339,7 @@ don't have to learn them the hard way.
303
339
  | Screenshots with transparency get rejected | Converts them to RGB |
304
340
  | Once a version is *Ready for Review*, most tools can no longer edit it | Uses a method that still works |
305
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 |
306
343
  | Apple's privacy section can't be reached by any API key | Prints the exact answers to paste in |
307
344
  | Accessibility labels can't publish before launch | Saved as a draft automatically |
308
345
  | In-app purchases need **two** different images, easily confused | Labels both slots |
@@ -313,8 +350,12 @@ don't have to learn them the hard way.
313
350
 
314
351
  ## What vydanne will never do
315
352
 
316
- - **It never submits your app.** A human attaches the build and presses Submit. That's on purpose.
317
- - 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.
318
359
  - It doesn't create the app record — make that in App Store Connect / Play Console first.
319
360
 
320
361
  <br>
@@ -337,6 +378,28 @@ Releasing a new version: [RELEASING.md](RELEASING.md).
337
378
  MIT.
338
379
 
339
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
+
340
403
  ### Accessibility Nutrition Labels
341
404
 
342
405
  Every other thing vydanne writes is a *fact* about your app. This one is a **claim about its
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,7 +166,15 @@ 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`.
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.
166
178
 
167
179
  `prerelease` uploads the BUILD. On Apple it validates and uploads the `.ipa` to **TestFlight** via
168
180
  `xcrun altool` — the one command that shells out, because the ASC REST API has never carried a binary,
@@ -181,23 +193,38 @@ comes from `google.track` / `VYDANNE_TRACK`, default `internal`; the bundle from
181
193
  `VYDANNE_AAB` (a directory takes its newest `.aab`). **For a PAID app use `internal`** — it's the only
182
194
  track where testers install without buying. Notes follow supply's layout:
183
195
  `<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.
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.
186
198
 
187
199
  `--store google` routes `inspect` · `diff` · `preflight` · `fill` · `prerelease` to the Play Developer **Edits** API
188
200
  (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.
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.
191
218
 
192
219
  **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).
220
+ `VYDANNE_REPLACE` (previews) · `VYDANNE_FLATTEN=<png>` (iap) · `VYDANNE_A11Y_PUBLISH` (accessibility) ·
221
+ `VYDANNE_COMMIT=1` (legacy alias for `--apply`; prefer the flag).
195
222
 
196
223
  ## Flow
197
224
 
198
225
  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**.
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**.
201
228
 
202
229
  ## Gotchas it encodes (don't re-derive)
203
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,17 +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;
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.`));
65
82
  // altool authenticates on its own rather than through our JWT, so it needs the raw ids.
66
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
+ }
67
92
  if (ok === false) process.exit(1);
68
93
  } else {
69
94
  console.error(usage());
@@ -75,20 +100,25 @@ try {
75
100
  }
76
101
 
77
102
  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)
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)
84
114
  privacy write the record + print the ASC-UI answers (API can't reach iris)
85
- previews upload App Preview videos (native chunked upload)
115
+ previews upload App Preview videos (native chunked upload)
86
116
  iap validate IAP fields; VYDANNE_FLATTEN=<png> flattens a screenshot to RGB
87
117
  compliance generate the US encryption self-classification PDF
88
118
  inspect read-only ASC state
89
119
  diff show what differs between local (metadata/screenshots/previews) and ASC
90
120
  preflight verify submission-completeness (the gotcha checker)
91
- prerelease upload the build for testers — .ipa to TestFlight (internal groups only),
121
+ prerelease upload the build for testers — .ipa to TestFlight (internal groups only),
92
122
  or --store google: the .aab to a closed track. Refuses production/review.
93
123
  locales UI -> ASC locale mapping + unsupported
94
124
  auth which credentials resolved, and from where (masked) — run this on a 401
@@ -96,5 +126,6 @@ credentials: env > .env cascade (.env, .env.<mode>, .env.local, .env.<mode>.loca
96
126
  (\$VYDANNE_CONFIG_HOME, %APPDATA%\\vydanne or \$XDG_CONFIG_HOME/vydanne, ~/.appstoreconnect).
97
127
  NEVER the committed vydanne.config.mjs — run \`vydanne auth\` to see what resolved.
98
128
  toggles: VYDANNE_SKIP_METADATA / VYDANNE_SKIP_SCREENSHOTS (fill), VYDANNE_A11Y_PUBLISH (accessibility),
99
- 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)`;
100
131
  }
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.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 }); }
@@ -28,6 +28,6 @@ export async function run(config, client) {
28
28
  };
29
29
  const r = await client.patch(`/v1/ageRatingDeclarations/${id}`, { data: { type: "ageRatingDeclarations", id, attributes } });
30
30
  if (r.status >= 300) { console.error(red(`age-rating: ${r.status}: ${JSON.stringify(r.json).slice(0, 200)}`)); return false; }
31
- console.log(green("age rating set -> 4+"));
31
+ console.log(client.dryRun ? yellow("age rating WOULD be set -> 4+") : green("age rating set -> 4+"));
32
32
  return true;
33
33
  }
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { green, yellow, red } from "../util.mjs";
4
4
  import { VALID } from "../locales.mjs";
5
5
  import { uploadAsset } from "../upload.mjs";
6
+ import { reportCrossStore } from "../crossStore.mjs";
6
7
 
7
8
  // Version-localization fields (attr -> metadata filename) and AppInfo fields (name/subtitle, shared).
8
9
  const VERSION_TXT = { description: "description", keywords: "keywords", promotionalText: "promotional_text", whatsNew: "release_notes", marketingUrl: "marketing_url", supportUrl: "support_url" };
@@ -50,6 +51,16 @@ export async function run(config, client) {
50
51
  let ok = true; // a locale Apple refused must fail the command, not just print
51
52
  const skipMeta = process.env.VYDANNE_SKIP_METADATA === "1";
52
53
  const skipShots = process.env.VYDANNE_SKIP_SCREENSHOTS === "1";
54
+
55
+ // Checked here and not only in preflight, because preflight is something you REMEMBER to run and
56
+ // this is the thing that actually uploads. A cross-store reference costs a review cycle, and it
57
+ // is free to catch one function call earlier. VYDANNE_ALLOW_CROSS_STORE=1 is the deliberate
58
+ // override for the rare listing that genuinely needs the word.
59
+ if (!skipMeta && process.env.VYDANNE_ALLOW_CROSS_STORE !== "1"
60
+ && !reportCrossStore("apple", config.metadataDir, config.allowCrossStoreTerms)) {
61
+ console.error(red("fill: refusing to upload — fix the listing text, or set VYDANNE_ALLOW_CROSS_STORE=1."));
62
+ return false;
63
+ }
53
64
  const info = await client.appInfo();
54
65
  const infoLocs = info ? (await client.get(`/v1/appInfos/${info.id}/appInfoLocalizations?limit=200`)).json.data || [] : [];
55
66
 
@@ -1,4 +1,5 @@
1
1
  import { green, red, yellow, LIMITS, VERSION_FIELDS } from "../util.mjs";
2
+ import { reportCrossStore } from "../crossStore.mjs";
2
3
 
3
4
  // Verify a listing is submission-complete the CORRECT way — each localization read by id (not the sparse
4
5
  // list), char limits, primary-locale coverage, per-platform — and warn on the gotchas before ASC does.
@@ -34,6 +35,11 @@ export async function run(config, client) {
34
35
  }
35
36
  }
36
37
 
38
+ // Local copy that is about to be uploaded, checked before it can earn a rejection.
39
+ if (!reportCrossStore("apple", config.metadataDir, config.allowCrossStoreTerms)) {
40
+ problems.push("listing text references another mobile platform (see above)");
41
+ }
42
+
37
43
  console.log();
38
44
  if (!problems.length) console.log(green("preflight: no blockers"));
39
45
  else { console.log(red(`preflight: ${problems.length} blocker(s)`)); problems.forEach((p) => console.log(` ${red("x")} ${p}`)); }
@@ -110,6 +110,14 @@ export async function run(config, client, credentials) {
110
110
  return false;
111
111
  }
112
112
 
113
+ // The binary upload is the one mutation that does NOT go through the ASC client, so the client-level
114
+ // dry-run gate cannot see it — it has to be refused here, or a dry run would ship a build to TestFlight.
115
+ // Validation above has already run, which is the useful half: exactly Play's "validate, then discard".
116
+ if (client.dryRun) {
117
+ console.log(yellow(" DRY RUN — archive validated, NOT uploaded. Re-run with --apply to send it to TestFlight."));
118
+ return true;
119
+ }
120
+
113
121
  try {
114
122
  await altool(["--upload-app", "-f", ipa, "-t", "ios"], credentials);
115
123
  console.log(green(" uploaded"));
@@ -24,17 +24,24 @@ export async function run(config, client) {
24
24
  }
25
25
  for (const p of existing) { // VYDANNE_REPLACE: drop the old preview so the new upload takes its place
26
26
  await client.del(`/v1/appPreviews/${p.id}`);
27
- console.log(yellow(` ${s.platform}/${code}/${s.type}: removed old preview ${p.id}`));
27
+ console.log(yellow(` ${s.platform}/${code}/${s.type}: ${client.dryRun ? "would remove" : "removed"} old preview ${p.id}`));
28
28
  }
29
29
  if (!set) {
30
30
  const c = await client.post(`/v1/appPreviewSets`, { data: { type: "appPreviewSets", attributes: { previewType: s.type }, relationships: { appStoreVersionLocalization: { data: { type: "appStoreVersionLocalizations", id: loc.id } } } } });
31
31
  set = c.json.data;
32
32
  }
33
33
  const file = path.resolve(s.file);
34
- console.log(` ${s.platform}/${code}/${s.type}: uploading ${path.basename(file)}...`);
34
+ // A configured preview whose file is missing is the whole reason this is checked here: the
35
+ // upload would throw ENOENT mid-run, and in a DRY run it would otherwise look like a plan that
36
+ // works. Name it and move on, so one missing video doesn't hide the rest of the report.
37
+ if (!fs.existsSync(file)) {
38
+ console.error(red(` ${s.platform}/${code}/${s.type}: ${path.relative(process.cwd(), file)} does not exist — nothing to upload`));
39
+ continue;
40
+ }
41
+ console.log(` ${s.platform}/${code}/${s.type}: ${client.dryRun ? "would upload" : "uploading"} ${path.basename(file)}...`);
35
42
  const id = await uploadAsset(client, { type: "appPreviews", setType: "appPreviewSet", setId: set.id, filePath: file });
36
43
  await setPreviewPoster(client, id, s.poster);
37
- console.log(green(` done ${s.platform}/${code}/${s.type}`));
44
+ if (!client.dryRun) console.log(green(` done ${s.platform}/${code}/${s.type}`));
38
45
  } catch (e) {
39
46
  console.error(red(` error ${s.platform}/${code}/${s.type}: ${e.message}`));
40
47
  }
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { green, red } from "../util.mjs";
3
+ import { green, yellow, red } from "../util.mjs";
4
4
 
5
5
  // App Review contact from the GITIGNORED metadata/review_information/*.txt. PATCH (or POST) the review
6
6
  // detail directly — deliver can't do this cleanly pre-first-submission.
@@ -22,6 +22,7 @@ export async function run(config, client) {
22
22
  ? await client.patch(`/v1/appStoreReviewDetails/${existing.id}`, { data: { type: "appStoreReviewDetails", id: existing.id, attributes } })
23
23
  : await client.post(`/v1/appStoreReviewDetails`, { data: { type: "appStoreReviewDetails", attributes, relationships: { appStoreVersion: { data: { type: "appStoreVersions", id: v.id } } } } });
24
24
  if (r.status >= 300) { console.error(red(`review-contact: ${r.status}: ${JSON.stringify(r.json).slice(0, 200)}`)); return false; }
25
- console.log(green(`review contact set -> ${attributes.contactFirstName} ${attributes.contactLastName} · ${attributes.contactPhone}`));
25
+ const who = `${attributes.contactFirstName} ${attributes.contactLastName} · ${attributes.contactPhone}`;
26
+ console.log(client.dryRun ? yellow(`review contact WOULD be set -> ${who}`) : green(`review contact set -> ${who}`));
26
27
  return true;
27
28
  }
package/src/config.mjs CHANGED
@@ -6,7 +6,7 @@ import { resolveCredentials } from "./credentials.mjs";
6
6
 
7
7
  // The public config surface — the drift guards assert each key is documented (README/SKILL) and typed
8
8
  // (types/index.d.ts). Add a config knob → document + type it, or the guards fail before publish.
9
- export const CONFIG_KEYS = ["bundleId", "primaryLocale", "asc", "platforms", "uiLocales", "metadataDir", "rating", "privacy", "iaps", "previews", "export", "ios", "google", "accessibility"];
9
+ export const CONFIG_KEYS = ["bundleId", "primaryLocale", "asc", "platforms", "uiLocales", "metadataDir", "rating", "privacy", "iaps", "previews", "export", "ios", "google", "accessibility", "allowCrossStoreTerms"];
10
10
 
11
11
  // One `vydanne.config.mjs` per app (ESM, like zdymak.config.mjs) — nothing hard-coded. Secrets stay out:
12
12
  // credentials resolve from the environment, a gitignored .env, or ~/.appstoreconnect/config.json (see
@@ -35,6 +35,8 @@ export async function loadConfig(p) {
35
35
  privacy: raw.privacy || { collected: ["CRASH_DATA", "PERFORMANCE_DATA"], tracking: false },
36
36
  iaps: raw.iaps || [],
37
37
  metadataDir: raw.metadataDir || "fastlane/metadata",
38
+ // Terms the cross-store check must not flag for this app (see src/crossStore.mjs).
39
+ allowCrossStoreTerms: raw.allowCrossStoreTerms || [],
38
40
  previews: raw.previews || null,
39
41
  export: raw.export || { encryption: "standard" },
40
42
  // Google Play. serviceAccountKey resolves from PLAY_JSON_KEY_FILE env first (keep the secret path out
@@ -0,0 +1,151 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { red, yellow, green } from "./util.mjs";
4
+
5
+ /**
6
+ * Refuse to send one store a listing that talks about the other one.
7
+ *
8
+ * WHY THIS IS IN THE TOOL AND NOT IN A CHECKLIST. Both stores forbid it, both enforce it by
9
+ * REJECTING a submission, and it is the single easiest thing to do by accident — because the two
10
+ * listings are written from the same source copy, by the same person, often by a translator who was
11
+ * handed an English master that happened to say "also on Google Play". Apple's App Review guideline
12
+ * 2.3.10 is explicit ("does not include names, icons, or imagery of other mobile platforms"), and
13
+ * Google Play's Store Listing and Promotion policy is the mirror of it. Nobody discovers this while
14
+ * writing the copy; they discover it days later, from a rejection, in one locale out of twenty.
15
+ *
16
+ * So it is checked HERE, against the local files that are about to be uploaded, before anything is
17
+ * sent. That is the only place it can be caught for free.
18
+ *
19
+ * WHAT IT DELIBERATELY DOES NOT DO. It does not flag a store's own platform — "Android" belongs in
20
+ * a Play listing and "iPhone" belongs in an App Store one. And it does not flag the bare word
21
+ * "Play", which is a verb every game listing on earth uses; only the store's actual name.
22
+ *
23
+ * Findings come in two tiers, because the certainty differs:
24
+ * block — unambiguous: a store name, a store URL, a competing platform's device name.
25
+ * warn — a word that is usually a reference but sometimes just a word ("apple" in a game about
26
+ * fruit). Reported, never fatal, and silenceable per app.
27
+ *
28
+ * An app with a genuine exception sets `allowCrossStoreTerms: ["..."]` in its vydanne config.
29
+ */
30
+
31
+ /** Terms that must not appear in a listing for [store]. Ordered longest-first so the report names
32
+ * the most specific match rather than a fragment of it. */
33
+ const FOREIGN = {
34
+ apple: [
35
+ { term: "play.google.com", re: /play\.google\.com/i, level: "block" },
36
+ { term: "Google Play", re: /google\s*play/i, level: "block" },
37
+ { term: "Play Store", re: /\bplay[- ]?store\b/i, level: "block" },
38
+ { term: "Play Market", re: /\bplay[- ]?market\b/i, level: "block" },
39
+ { term: "Android", re: /\bandroid\b/i, level: "block" },
40
+ { term: "安卓", re: /安卓/, level: "block" },
41
+ { term: "アンドロイド", re: /アンドロイド/, level: "block" },
42
+ { term: "안드로이드", re: /안드로이드/, level: "block" },
43
+ { term: "Андроид/Андроїд", re: /андро[иї]д/i, level: "block" },
44
+ { term: "أندرويد", re: /أندرويد/, level: "block" },
45
+ { term: "אנדרואיד", re: /אנדרואיד/, level: "block" },
46
+ { term: "एंड्रॉइड", re: /एंड्रॉ?इड/, level: "block" },
47
+ { term: "APK", re: /\bapk\b/i, level: "warn" },
48
+ { term: "Chromebook", re: /\bchromebook\b/i, level: "warn" },
49
+ ],
50
+ google: [
51
+ { term: "apps.apple.com", re: /apps\.apple\.com/i, level: "block" },
52
+ { term: "App Store", re: /\bapp[- ]?store\b/i, level: "block" },
53
+ { term: "TestFlight", re: /\btestflight\b/i, level: "block" },
54
+ { term: "iPhone", re: /\biphone\b/i, level: "block" },
55
+ { term: "iPad", re: /\bipad(os)?\b/i, level: "block" },
56
+ { term: "iOS", re: /\bios\b/i, level: "block" },
57
+ { term: "Apple Arcade", re: /\bapple\s+arcade\b/i, level: "block" },
58
+ { term: "苹果", re: /苹果/, level: "block" },
59
+ { term: "애플", re: /애플/, level: "block" },
60
+ { term: "アップル", re: /アップル/, level: "block" },
61
+ // Sometimes a fruit, so it is reported rather than fatal.
62
+ { term: "Apple", re: /\bapple\b/i, level: "warn" },
63
+ ],
64
+ };
65
+
66
+ /** Local text a store actually uploads. Keys are the file basenames each fill() reads. */
67
+ const APPLE_FILES = [
68
+ "name", "subtitle", "description", "keywords", "promotional_text",
69
+ "release_notes", "marketing_url", "support_url",
70
+ ];
71
+ const PLAY_FILES = ["title", "short_description", "full_description"];
72
+
73
+ /** One short line of context so a finding can be found and fixed without opening the file blind. */
74
+ function excerpt(text, match) {
75
+ const at = text.toLowerCase().indexOf(match.toLowerCase());
76
+ if (at < 0) return "";
77
+ const from = Math.max(0, at - 28);
78
+ const to = Math.min(text.length, at + match.length + 28);
79
+ return `${from ? "…" : ""}${text.slice(from, to).replace(/\s+/g, " ")}${to < text.length ? "…" : ""}`;
80
+ }
81
+
82
+ /**
83
+ * Scan the local metadata tree for [store].
84
+ *
85
+ * @returns {{findings: Array, scanned: number}} findings carry {level, locale, field, term, excerpt}
86
+ */
87
+ export function scanCrossStore(store, metadataDir, allow = []) {
88
+ const rules = FOREIGN[store].filter((r) => !allow.some((a) => a.toLowerCase() === r.term.toLowerCase()));
89
+ const files = store === "apple" ? APPLE_FILES : PLAY_FILES;
90
+ const findings = [];
91
+ let scanned = 0;
92
+ if (!metadataDir || !fs.existsSync(metadataDir)) return { findings, scanned };
93
+
94
+ for (const entry of fs.readdirSync(metadataDir, { withFileTypes: true })) {
95
+ if (!entry.isDirectory()) continue;
96
+ for (const field of files) {
97
+ const p = path.join(metadataDir, entry.name, `${field}.txt`);
98
+ if (!fs.existsSync(p)) continue;
99
+ const text = fs.readFileSync(p, "utf8");
100
+ scanned++;
101
+ for (const rule of rules) {
102
+ const m = text.match(rule.re);
103
+ if (!m) continue;
104
+ findings.push({
105
+ level: rule.level,
106
+ locale: entry.name,
107
+ field,
108
+ term: rule.term,
109
+ excerpt: excerpt(text, m[0]),
110
+ });
111
+ }
112
+ }
113
+ }
114
+ return { findings, scanned };
115
+ }
116
+
117
+ /** The other store's human name, for the message. */
118
+ const OTHER = { apple: "Google Play / Android", google: "the App Store / iOS" };
119
+ const GUIDELINE = {
120
+ apple: "App Review guideline 2.3.10 — metadata must not name other mobile platforms.",
121
+ google: "Google Play Store Listing and Promotion policy — no references to other app stores.",
122
+ };
123
+
124
+ /**
125
+ * Report findings. Returns false when something BLOCKING was found, so callers can refuse to upload.
126
+ *
127
+ * Printing every locale rather than the first is the point: this fails one translation at a time,
128
+ * and knowing it is 3 locales and not 20 is the difference between a fix and a re-translation.
129
+ */
130
+ export function reportCrossStore(store, metadataDir, allow = []) {
131
+ const { findings, scanned } = scanCrossStore(store, metadataDir, allow);
132
+ if (!scanned) return true;
133
+
134
+ const blocking = findings.filter((f) => f.level === "block");
135
+ const warnings = findings.filter((f) => f.level === "warn");
136
+
137
+ if (blocking.length) {
138
+ console.log(red(` cross-store: ${blocking.length} reference(s) to ${OTHER[store]} in listing text`));
139
+ console.log(` ${GUIDELINE[store]}`);
140
+ for (const f of blocking) {
141
+ console.log(` ${red("x")} ${f.locale}/${f.field}: "${f.term}" ${f.excerpt}`);
142
+ }
143
+ }
144
+ for (const f of warnings) {
145
+ console.log(` ${yellow("!")} ${f.locale}/${f.field}: "${f.term}" — check this is not a platform reference ${f.excerpt}`);
146
+ }
147
+ if (!blocking.length && !warnings.length) {
148
+ console.log(green(` cross-store: clean (${scanned} files)`));
149
+ }
150
+ return blocking.length === 0;
151
+ }
@@ -9,13 +9,16 @@ const UPLOAD = "https://androidpublisher.googleapis.com/upload/androidpublisher/
9
9
  // transaction — insert an edit, mutate listings/images/details against it, then commit (all-or-nothing).
10
10
  // Nothing is live until commit; a dropped edit changes nothing. Image bytes go to the /upload endpoint.
11
11
  export class PlayClient {
12
- static async create({ keyPath, packageName }) {
12
+ static async create({ keyPath, packageName, dryRun = false }) {
13
13
  const token = await getAccessToken(keyPath);
14
- return new PlayClient(token, packageName);
14
+ return new PlayClient(token, packageName, dryRun);
15
15
  }
16
- constructor(token, packageName) {
16
+ constructor(token, packageName, dryRun = false) {
17
17
  this.token = token;
18
18
  this.pkg = packageName;
19
+ // Gates the COMMIT, not the requests: the edit is still built and validated against Google for real,
20
+ // which is the whole advantage of Play's transaction over Apple's fire-and-forget PATCHes.
21
+ this.dryRun = dryRun;
19
22
  }
20
23
 
21
24
  async req(method, subpath, { body, base = BASE } = {}) {
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { green, yellow, red } from "../../util.mjs";
4
+ import { reportCrossStore } from "../../crossStore.mjs";
4
5
 
5
6
  const FIELDS = [["title", "title"], ["shortDescription", "short_description"], ["fullDescription", "full_description"]];
6
7
  // Play image type -> local source (a dir of PNGs = screenshots; a single file = graphic). From zdymak.
@@ -16,12 +17,12 @@ const IMAGES = [
16
17
 
17
18
  // Push the Play listing (text + images) inside one Edit, then validate and commit. iOS/Android are separate
18
19
  // stores — this is the Google half. Images only touch a type whose local asset EXISTS (so a missing local
19
- // set never deletes the live one). VYDANNE_DRY=1 validates and discards without committing.
20
+ // set never deletes the live one). Without `--apply` it validates and discards without committing.
20
21
  export async function run(config, client) {
21
22
  const g = config.google;
22
- // SAFE BY DEFAULT: validate + discard the edit unless VYDANNE_COMMIT=1. A store-mutating commit must be
23
- // an explicit opt-in — never the default (a stale/partial local set could otherwise clobber a live one).
24
- const commit = process.env.VYDANNE_COMMIT === "1";
23
+ // SAFE BY DEFAULT: validate + discard the edit unless `--apply`. A store-mutating commit must be an
24
+ // explicit opt-in — never the default (a stale/partial local set could otherwise clobber a live one).
25
+ const commit = !client.dryRun;
25
26
  const localLangs = fs.existsSync(g.metadataDir)
26
27
  ? fs.readdirSync(g.metadataDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name)
27
28
  : [];
@@ -31,6 +32,12 @@ export async function run(config, client) {
31
32
  return true;
32
33
  }
33
34
 
35
+ if (process.env.VYDANNE_ALLOW_CROSS_STORE !== "1"
36
+ && !reportCrossStore("google", g.metadataDir, config.allowCrossStoreTerms)) {
37
+ console.error(red("fill(play): refusing to upload — fix the listing text, or set VYDANNE_ALLOW_CROSS_STORE=1."));
38
+ return false;
39
+ }
40
+
34
41
  const editId = await client.newEdit();
35
42
  try {
36
43
  // Listing text
@@ -57,7 +64,7 @@ export async function run(config, client) {
57
64
 
58
65
  const v = await client.validate(editId);
59
66
  if (v.status >= 300) throw new Error(`validate ${v.status}: ${JSON.stringify(v.json).slice(0, 200)}`);
60
- if (!commit) { await client.deleteEdit(editId); console.log(yellow("fill(play): validated — DRY (nothing changed). Review the above, then set VYDANNE_COMMIT=1 to commit.")); return true; }
67
+ if (!commit) { await client.deleteEdit(editId); console.log(yellow("fill(play): validated — DRY (nothing changed). Review the above, then re-run with --apply to commit.")); return true; }
61
68
  const co = await client.commit(editId);
62
69
  if (co.status >= 300) throw new Error(`commit ${co.status}: ${JSON.stringify(co.json).slice(0, 200)}`);
63
70
  console.log(green("fill(play): committed."));
@@ -1,4 +1,5 @@
1
1
  import { green, red, yellow } from "../../util.mjs";
2
+ import { reportCrossStore } from "../../crossStore.mjs";
2
3
 
3
4
  // Play listing limits.
4
5
  const LIMITS = { title: 30, shortDescription: 80, fullDescription: 4000 };
@@ -30,6 +31,10 @@ export async function run(config, client) {
30
31
  } finally {
31
32
  await client.deleteEdit(editId);
32
33
  }
34
+ if (!reportCrossStore("google", g.metadataDir, config.allowCrossStoreTerms)) {
35
+ problems.push("listing text references another app store (see above)");
36
+ }
37
+
33
38
  console.log();
34
39
  if (!problems.length) console.log(green("preflight: no blockers"));
35
40
  else { console.log(red(`preflight: ${problems.length} blocker(s)`)); problems.forEach((p) => console.log(` ${red("x")} ${p}`)); }
@@ -79,9 +79,9 @@ export async function run(config, client) {
79
79
  const put = await client.putTrack(editId, track, [release]);
80
80
  if (put.status >= 300) throw new Error(`tracks.update ${put.status}: ${JSON.stringify(put.json).slice(0, 300)}`);
81
81
 
82
- if (process.env.VYDANNE_COMMIT !== "1") {
82
+ if (client.dryRun) {
83
83
  await client.deleteEdit(editId);
84
- console.log(yellow(`\n DRY RUN — edit discarded, nothing changed. Re-run with VYDANNE_COMMIT=1 to publish to "${track}".`));
84
+ console.log(yellow(`\n DRY RUN — edit discarded, nothing changed. Re-run with --apply to publish to "${track}".`));
85
85
  return true;
86
86
  }
87
87
  const res = await client.commit(editId);
package/src/registry.mjs CHANGED
@@ -1,14 +1,21 @@
1
1
  // The canonical command registry — the single source of vydanne's public commands. bin/ dispatches from
2
2
  // this, and the drift guards (scripts/check-docs.mjs, scripts/check-types.mjs) assert every command is
3
3
  // documented in README/SKILL and typed in types/index.d.ts. Add a command here → the guards force it into
4
- // the docs + types before publish. name -> { mod: <file in src/commands>, client: needs an ASC client }
4
+ // the docs + types before publish.
5
+ //
6
+ // name -> { mod: <file in src/commands>, client: needs an ASC client, writes: mutates the STORE }
7
+ //
8
+ // `writes` is what makes a command dry-run unless `--apply` is passed, so it is a safety declaration, not
9
+ // a label: mark a new command `writes: true` the moment it can change anything on the store side. It means
10
+ // the STORE specifically — `privacy` and `compliance` write local files (a record, a PDF) and are not
11
+ // marked, because a dry run that refused to produce a local artefact would just be broken.
5
12
  export const COMMANDS = {
6
- fill: { mod: "fill", client: true },
7
- "age-rating": { mod: "ageRating", client: true },
8
- "review-contact": { mod: "reviewContact", client: true },
9
- accessibility: { mod: "accessibility", client: true },
13
+ fill: { mod: "fill", client: true, writes: true },
14
+ "age-rating": { mod: "ageRating", client: true, writes: true },
15
+ "review-contact": { mod: "reviewContact", client: true, writes: true },
16
+ accessibility: { mod: "accessibility", client: true, writes: true },
10
17
  privacy: { mod: "privacy", client: false },
11
- previews: { mod: "previews", client: true },
18
+ previews: { mod: "previews", client: true, writes: true },
12
19
  iap: { mod: "iap", client: false },
13
20
  compliance: { mod: "compliance", client: false },
14
21
  inspect: { mod: "inspect", client: true },
@@ -16,7 +23,7 @@ export const COMMANDS = {
16
23
  preflight: { mod: "preflight", client: true },
17
24
  // Uploads the .ipa to TestFlight. Needs the credentials as well as the client: the REST API
18
25
  // cannot carry a binary, so this one shells out to `xcrun altool`, which authenticates itself.
19
- prerelease: { mod: "prerelease", client: true, credentials: true },
26
+ prerelease: { mod: "prerelease", client: true, credentials: true, writes: true },
20
27
  };
21
28
 
22
29
  // Commands available for `--store google` (Google Play). Same names as the Apple ones, different backend
@@ -25,8 +32,8 @@ export const PLAY_COMMANDS = {
25
32
  inspect: { mod: "inspect" },
26
33
  preflight: { mod: "preflight" },
27
34
  diff: { mod: "diff" },
28
- fill: { mod: "fill" },
29
- prerelease: { mod: "prerelease" },
35
+ fill: { mod: "fill", writes: true },
36
+ prerelease: { mod: "prerelease", writes: true },
30
37
  };
31
38
 
32
39
  // Full public command surface (the module-dispatched ones above + the three handled inline in bin/).
package/src/upload.mjs CHANGED
@@ -33,6 +33,9 @@ export async function uploadAsset(client, { type, setType, setId, filePath }) {
33
33
 
34
34
  // Previews process asynchronously — poll until Apple exposes videoUrl, then set the poster frame.
35
35
  export async function setPreviewPoster(client, previewId, frameTimeCode, { tries = 30, delayMs = 15000 } = {}) {
36
+ // In a dry run the preview was never created, so `previewId` is a synthetic `dry-run-<n>` and this would
37
+ // poll a 404 for seven and a half minutes before giving up.
38
+ if (client.dryRun) return true;
36
39
  for (let i = 0; i < tries; i++) {
37
40
  const { json } = await client.get(`/v1/appPreviews/${previewId}`);
38
41
  if (json.data?.attributes?.videoUrl) {
package/types/index.d.ts CHANGED
@@ -139,18 +139,32 @@ export interface VydanneConfig {
139
139
  ios?: IosConfig;
140
140
  /** Google Play (`--store google`): listings, screenshots, feature graphic via the Edits API. */
141
141
  google?: GoogleConfig;
142
+ /**
143
+ * Terms the cross-store check must not flag for this app.
144
+ *
145
+ * `preflight` and `fill` refuse listing text that names the OTHER mobile platform — App Review
146
+ * guideline 2.3.10 and Google Play's Store Listing and Promotion policy both reject it. Use this
147
+ * only for a word that genuinely belongs in your copy (a game about fruit really does say
148
+ * "apple"); it is not a way to ship a store name.
149
+ */
150
+ allowCrossStoreTerms?: string[];
142
151
  }
143
152
 
144
153
  /** Thin ASC REST client (native fetch + ES256 JWT). */
145
154
  export declare class Client {
146
- constructor(opts: { keyId: string; issuerId: string });
155
+ constructor(opts: { keyId: string; issuerId: string; dryRun?: boolean });
147
156
  token: string;
148
157
  appId?: string;
149
158
  app?: unknown;
159
+ /** When true, no POST/PATCH/PUT/DELETE leaves the process — each is recorded in `planned` instead. */
160
+ dryRun: boolean;
161
+ /** The mutations a real run would have sent, in order. Populated only while `dryRun`. */
162
+ planned: Array<{ method: string; path: string; attributes: Record<string, unknown> }>;
150
163
  findApp(bundleId: string): Promise<unknown>;
151
164
  get(path: string, opts?: { iris?: boolean }): Promise<{ status: number; json: any }>;
152
165
  post(path: string, body: unknown): Promise<{ status: number; json: any }>;
153
166
  patch(path: string, body: unknown): Promise<{ status: number; json: any }>;
167
+ del(path: string): Promise<{ status: number; json: any }>;
154
168
  editVersion(platform: Platform): Promise<any>;
155
169
  appInfo(): Promise<any>;
156
170
  versionLocalizations(versionId: string): Promise<any[]>;
@@ -164,6 +178,7 @@ export declare function toAsc(code: string): string | null;
164
178
  export declare const VALID: Set<string>;
165
179
  export declare const CONFIG_KEYS: readonly string[];
166
180
  export declare const COMMAND_NAMES: readonly CommandName[];
167
- export declare const COMMANDS: Record<string, { mod: string; client: boolean }>;
181
+ /** `writes` marks a command that mutates the STORE those are dry-run unless the CLI gets `--apply`. */
182
+ export declare const COMMANDS: Record<string, { mod: string; client: boolean; writes?: boolean }>;
168
183
 
169
184
  export default VydanneConfig;