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