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 +75 -12
- package/SKILL.md +38 -11
- package/bin/vydanne.mjs +43 -12
- package/package.json +2 -2
- package/src/client.mjs +36 -1
- package/src/commands/ageRating.mjs +1 -1
- package/src/commands/fill.mjs +11 -0
- package/src/commands/preflight.mjs +6 -0
- package/src/commands/prerelease.mjs +8 -0
- package/src/commands/previews.mjs +10 -3
- package/src/commands/reviewContact.mjs +3 -2
- package/src/config.mjs +3 -1
- package/src/crossStore.mjs +151 -0
- package/src/play/client.mjs +6 -3
- package/src/play/commands/fill.mjs +12 -5
- package/src/play/commands/preflight.mjs +5 -0
- package/src/play/commands/prerelease.mjs +2 -2
- package/src/registry.mjs +16 -9
- package/src/upload.mjs +3 -0
- package/types/index.d.ts +17 -2
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 #
|
|
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
|
|
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
|
|
195
|
-
|
|
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
|
|
248
|
-
|
|
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
|
|
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
|
|
317
|
-
|
|
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.
|
|
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
|
|
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
|
|
185
|
-
|
|
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
|
-
|
|
190
|
-
|
|
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
|
-
`
|
|
194
|
-
`
|
|
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
|
|
200
|
-
|
|
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
|
|
57
|
-
const
|
|
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
|
|
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
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
}
|
package/src/commands/fill.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/play/client.mjs
CHANGED
|
@@ -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).
|
|
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
|
|
23
|
-
//
|
|
24
|
-
const commit =
|
|
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
|
|
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 (
|
|
82
|
+
if (client.dryRun) {
|
|
83
83
|
await client.deleteEdit(editId);
|
|
84
|
-
console.log(yellow(`\n DRY RUN — edit discarded, nothing changed. Re-run with
|
|
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.
|
|
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
|
-
|
|
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;
|