vydanne 0.2.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 +434 -0
- package/LICENSE +21 -0
- package/README.md +212 -0
- package/SKILL.md +174 -0
- package/bin/vydanne.mjs +66 -0
- package/package.json +66 -0
- package/src/client.mjs +65 -0
- package/src/commands/accessibility.mjs +36 -0
- package/src/commands/ageRating.mjs +33 -0
- package/src/commands/compliance.mjs +45 -0
- package/src/commands/diff.mjs +107 -0
- package/src/commands/fill.mjs +84 -0
- package/src/commands/iap.mjs +31 -0
- package/src/commands/inspect.mjs +20 -0
- package/src/commands/preflight.mjs +42 -0
- package/src/commands/previews.mjs +51 -0
- package/src/commands/privacy.mjs +29 -0
- package/src/commands/reviewContact.mjs +27 -0
- package/src/config.mjs +48 -0
- package/src/index.mjs +6 -0
- package/src/jwt.mjs +17 -0
- package/src/locales.mjs +26 -0
- package/src/play/auth.mjs +29 -0
- package/src/play/client.mjs +68 -0
- package/src/play/commands/diff.mjs +47 -0
- package/src/play/commands/fill.mjs +69 -0
- package/src/play/commands/inspect.mjs +24 -0
- package/src/play/commands/preflight.mjs +37 -0
- package/src/registry.mjs +29 -0
- package/src/upload.mjs +47 -0
- package/src/util.mjs +9 -0
- package/types/index.d.ts +119 -0
- package/vydanne.config.example.mjs +56 -0
package/SKILL.md
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: vydanne
|
|
3
|
+
description: Prepare an App Store Connect / Google Play submission — write AND push the localized store listing. Crafts the ASO copy (app-store name, subtitle, the 100-char keyword field, description, promo text), then fills the listing + screenshots + previews, age rating, review contact, accessibility & App Privacy labels, IAP fields, and export-compliance docs; verifies with a preflight gate and diffs local-vs-live. Native Node (ES256 JWT + fetch, no fastlane/Ruby). One vydanne.config.mjs per app. Use when writing or shipping any App Store / Play listing. Never submits — a human attaches the signed build and hits Submit.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# vydanne
|
|
7
|
+
|
|
8
|
+
The App Store Connect / Google Play half of a two-part release pipeline. Native Node — no
|
|
9
|
+
fastlane/Ruby/Python. Two jobs: **(A) write the listing well (ASO)**, **(B) push it + the declarations**.
|
|
10
|
+
|
|
11
|
+
**Companion tool — [zdymak](https://www.npmjs.com/package/zdymak)** makes the *media* (screenshots, App
|
|
12
|
+
Preview videos, Play feature graphic); vydanne pushes that media plus all the *text and paperwork*. If the
|
|
13
|
+
user needs screenshots or a preview video produced, that's zdymak's job, not vydanne's — vydanne only
|
|
14
|
+
uploads files that already exist. zdymak's default output paths are exactly the paths vydanne reads for
|
|
15
|
+
Play images (below), so the two line up with no glue.
|
|
16
|
+
|
|
17
|
+
**Never submits.** A human attaches the signed build and presses Submit. Don't try to work around this.
|
|
18
|
+
|
|
19
|
+
## Setup
|
|
20
|
+
|
|
21
|
+
`npm i -D vydanne`, then **`npx vydanne <cmd>` from the app's repo root** — the config and every relative
|
|
22
|
+
path in it resolve against the working directory. Running from a subfolder silently reads the wrong paths.
|
|
23
|
+
|
|
24
|
+
One `vydanne.config.mjs` per app (schema: `vydanne.config.example.mjs`; type
|
|
25
|
+
`import('vydanne').VydanneConfig`). Auth: `~/.appstoreconnect/private_keys/AuthKey_<keyId>.p8` +
|
|
26
|
+
`ASC_KEY_ID` / `ASC_ISSUER_ID`; Play uses `PLAY_JSON_KEY_FILE`. Node ≥20.9. Cross-platform — the key path
|
|
27
|
+
resolves via `os.homedir()`, so it works on Windows (`C:\Users\<you>\.appstoreconnect\…`). On Windows
|
|
28
|
+
PowerShell the `VAR=1 cmd` form does **not** exist; set `$env:VAR = "1"` first.
|
|
29
|
+
|
|
30
|
+
**Config fields:** `bundleId` · `primaryLocale` (the fallback — must be populated) · `asc` (optional
|
|
31
|
+
`{keyId, issuerId}`) · `platforms` (iOS and macOS are SEPARATE) · `uiLocales` (auto-mapped to ASC codes) ·
|
|
32
|
+
`metadataDir` · `rating` · `privacy` · `iaps` · `previews` · `export` · `google` (Google Play).
|
|
33
|
+
|
|
34
|
+
For a non-technical user asking how to set this up from scratch, walk them through
|
|
35
|
+
**`GETTING_STARTED.md`** (accounts → API key → config → folders → push) rather than improvising.
|
|
36
|
+
|
|
37
|
+
## File layout — read this before writing any file
|
|
38
|
+
|
|
39
|
+
vydanne reads plain `.txt` files from fixed locations. **These conventions are not configurable beyond
|
|
40
|
+
`metadataDir`; do not invent paths.**
|
|
41
|
+
|
|
42
|
+
**Apple listing text** — `<metadataDir>/<ASC-locale>/*.txt` (default `metadataDir`: `fastlane/metadata`):
|
|
43
|
+
|
|
44
|
+
| File | Field | Limit |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| `name.txt` | app name (on **AppInfo**, shared across platforms) | 30 |
|
|
47
|
+
| `subtitle.txt` | subtitle (on AppInfo) | 30 |
|
|
48
|
+
| `description.txt` | description (on the **version**) | 4000 |
|
|
49
|
+
| `keywords.txt` | keyword field (version) | 100 |
|
|
50
|
+
| `promotional_text.txt` | promo text (version) | 170 |
|
|
51
|
+
| `release_notes.txt` | what's new (version) | — |
|
|
52
|
+
| `marketing_url.txt`, `support_url.txt` | optional URLs (version) | — |
|
|
53
|
+
|
|
54
|
+
Folder names must be **Apple's exact ASC codes** (`de-DE`, `ar-SA`, `zh-Hans`, `en-GB`…). A folder whose
|
|
55
|
+
name isn't in the valid set is skipped; run `vydanne locales` to get the mapping from the config's
|
|
56
|
+
`uiLocales`. A language with no App Store equivalent (e.g. Belarusian `be`) must **not** get a folder — it
|
|
57
|
+
falls back to `primaryLocale`.
|
|
58
|
+
|
|
59
|
+
**App Review contact** — `<metadataDir>/review_information/{first_name,last_name,phone_number,email_address,notes}.txt`.
|
|
60
|
+
This is PII: keep it gitignored.
|
|
61
|
+
|
|
62
|
+
**Apple screenshots** — `fastlane/screenshots/<ASC-locale>/<prefix>_<anything>.png`, and
|
|
63
|
+
`fastlane/screenshots-macos/<ASC-locale>/…` for Mac. **These two base paths are hardcoded.** The token
|
|
64
|
+
before the **first underscore** selects the device slot; files upload in sorted order, so number them:
|
|
65
|
+
|
|
66
|
+
| Prefix | Slot |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `iphone69_` | `APP_IPHONE_67` (6.9″) |
|
|
69
|
+
| `iphone65_` | `APP_IPHONE_65` |
|
|
70
|
+
| `ipad13_` | `APP_IPAD_PRO_3GEN_129` |
|
|
71
|
+
| `watch_` | `APP_WATCH_ULTRA` |
|
|
72
|
+
| `macos_` | `APP_DESKTOP` (in `screenshots-macos/`) |
|
|
73
|
+
|
|
74
|
+
An unknown prefix is silently ignored. A set that **already has screenshots is skipped**, never
|
|
75
|
+
duplicated — to replace shots, delete them in ASC first. PNGs must be **RGB with no alpha**.
|
|
76
|
+
|
|
77
|
+
**Play listing text** — `<google.metadataDir>/<PLAY-locale>/{title,short_description,full_description}.txt`
|
|
78
|
+
(30 / 80 / 4000). Play uses its **own** codes (`de-DE`, `zh-CN`, `iw-IL`, `ar`, `be`) — *not* Apple's
|
|
79
|
+
`zh-Hans`/`he`/`ar-SA`.
|
|
80
|
+
|
|
81
|
+
**Play images** — hardcoded source paths (zdymak's output), each pushed only when the file exists, so a
|
|
82
|
+
missing local set never deletes the live one: `brand/icons/play/icon-512.png` (512²) ·
|
|
83
|
+
`marketing/out/play-feature-graphic.png` (1024×500) · `marketing/out/play-phone-plain/` ·
|
|
84
|
+
`marketing/out/play-tablet7-plain/` (7″) · `marketing/out/play-tablet-plain/` (10″).
|
|
85
|
+
|
|
86
|
+
## A. Writing the listing (the ASO craft — the durable value)
|
|
87
|
+
|
|
88
|
+
**Write the English master first, then localize.**
|
|
89
|
+
|
|
90
|
+
### The fields, what they're FOR, and the hard limits
|
|
91
|
+
|
|
92
|
+
| Field | Limit | Indexed for search? | Job |
|
|
93
|
+
|---|---|---|---|
|
|
94
|
+
| `name` | 30 | **Yes (highest weight)** | Brand + the single strongest keyword. Must be globally UNIQUE. |
|
|
95
|
+
| `subtitle` | 30 | **Yes** | A second keyword-bearing benefit line — NOT a repeat of the name. |
|
|
96
|
+
| `keywords` (App Store only) | 100 | **Yes** | The biggest lever. Comma-separated, **no spaces**. |
|
|
97
|
+
| `promotional_text` | 170 | No | Rotating hook (sale/seasonal); editable anytime with no review. |
|
|
98
|
+
| `description` | 4000 | **App Store: NO** / **Play: YES** | Conversion copy for the human. On Play it's also indexed → bake keywords in naturally. |
|
|
99
|
+
| Play `title`/`short`/`full` | 30/80/4000 | short + full **Yes** | Play has NO keyword field → keywords go in title + short + full. |
|
|
100
|
+
|
|
101
|
+
### The keyword field — the rules people get wrong (App Store)
|
|
102
|
+
|
|
103
|
+
- **No spaces** after commas (`a,b,c` not `a, b, c`) — every char counts toward 100. **Fill all 100.**
|
|
104
|
+
- **Don't repeat** any word already in `name` or `subtitle` — Apple already indexes those; repeating wastes the field.
|
|
105
|
+
- **Singular OR plural, never both** — Apple matches both stems; pick one (usually singular).
|
|
106
|
+
- **Omit** "app", "game", "free", and the app's own name — Apple indexes those automatically.
|
|
107
|
+
- Apple **auto-combines** keywords into phrases (kw+kw), so prefer **single words** to maximize combinations — no multi-word phrases unless the phrase is the exact search term.
|
|
108
|
+
- **No competitor trademarks** (rejection risk, especially for games).
|
|
109
|
+
- Prioritize by **relevance × search volume × achievable rank**: generic head terms are hard to rank; mid-tail terms convert AND rank. Order best-first (leading keywords weigh more).
|
|
110
|
+
|
|
111
|
+
### Positioning — ground it in the app's real audience, not generic hype
|
|
112
|
+
|
|
113
|
+
Before writing, establish what actually motivates *this* app's users (ask the user, or read whatever
|
|
114
|
+
audience research/positioning docs the repo has). Then:
|
|
115
|
+
|
|
116
|
+
- **Lead with the strongest shared motivation**, stated concretely — not a feature list.
|
|
117
|
+
- **Name the genuine differentiator plainly.** If there's one thing competitors can't claim, say it in
|
|
118
|
+
the subtitle, not buried in paragraph four.
|
|
119
|
+
- **Match the audience's temperature.** A calm, premium audience reacts badly to hype punctuation and
|
|
120
|
+
competitive framing; a competitive audience finds understatement flat. Mirror the in-app voice.
|
|
121
|
+
- **Be honest in the close** — "no ads", "no account", "buy once" only if true. Claims here are checkable.
|
|
122
|
+
- Don't lead with mechanics the audience doesn't care about (leaderboards, streaks) just because they exist.
|
|
123
|
+
|
|
124
|
+
### Description shape (App Store & Play)
|
|
125
|
+
|
|
126
|
+
Hook (1–2 lines: the promise) → **WHY \<APP\>** (3–5 benefit bullets, each benefit-first) → what it is /
|
|
127
|
+
who it's for → honest close. Keep it scannable; lead each bullet with the payoff, not the feature.
|
|
128
|
+
|
|
129
|
+
### Localizing the listing (transcreation, not translation)
|
|
130
|
+
|
|
131
|
+
- **App name**: the brand is **never** translated; the DESCRIPTOR may be localized per store locale.
|
|
132
|
+
- **Keywords**: use the target market's **actual search terms**, not a dictionary translation. Research
|
|
133
|
+
per market — the literal translation of a category name is often not what people type.
|
|
134
|
+
- **Subtitle / promo / description**: **transcreate** — adapt benefit + tone naturally; never word-for-word.
|
|
135
|
+
- **Fan out one agent per locale** (a Sonnet-tier translator/copywriter is the right size). Give each:
|
|
136
|
+
the master English copy, the target locale, its `<metadataDir>/<ASC-locale>/` directory, the brand name
|
|
137
|
+
(untranslated), the character limits, and the positioning above. Validate limits after the merge —
|
|
138
|
+
translations routinely blow the 30-char fields.
|
|
139
|
+
|
|
140
|
+
## B. Commands (push it)
|
|
141
|
+
|
|
142
|
+
`fill` (metadata + screenshots, native PATCH/chunked upload — works even at READY_FOR_REVIEW) ·
|
|
143
|
+
`previews` (App Preview videos) · `age-rating` · `review-contact` · `accessibility` (draft; publish once
|
|
144
|
+
live) · `privacy` (prints answers for the UI — the API can't reach Apple's iris host) · `iap` (validate +
|
|
145
|
+
RGB flatten) · `compliance` (US self-classification PDF) · `diff` (what differs vs live) · `preflight`
|
|
146
|
+
(completeness gate) · `inspect` · `locales` · `version`.
|
|
147
|
+
|
|
148
|
+
`--store google` routes `inspect` · `diff` · `preflight` · `fill` to the Play Developer **Edits** API
|
|
149
|
+
(OAuth2 service account; **scoped to the config's `packageName`** — a shared key can't touch another app).
|
|
150
|
+
`fill --store google` is **DRY by default**; `VYDANNE_COMMIT=1` commits. The AAB binary and the
|
|
151
|
+
(YouTube-URL) promo video stay outside vydanne.
|
|
152
|
+
|
|
153
|
+
**Env toggles:** `VYDANNE_CONFIG` · `VYDANNE_SKIP_METADATA` / `VYDANNE_SKIP_SCREENSHOTS` (fill) ·
|
|
154
|
+
`VYDANNE_COMMIT` (Play fill) · `VYDANNE_REPLACE` (previews) · `VYDANNE_FLATTEN=<png>` (iap) ·
|
|
155
|
+
`VYDANNE_A11Y_PUBLISH` (accessibility).
|
|
156
|
+
|
|
157
|
+
## Flow
|
|
158
|
+
|
|
159
|
+
config → **write the English master listing (ASO, research-grounded)** → `preflight` (char limits) → fan
|
|
160
|
+
out one copywriter agent per locale → media from zdymak → `fill` + `previews` + declarations → `diff`
|
|
161
|
+
(dry-run) → `preflight` (must be green) → **a human submits**.
|
|
162
|
+
|
|
163
|
+
## Gotchas it encodes (don't re-derive)
|
|
164
|
+
|
|
165
|
+
ASC locale folder codes must be exact (`de`→`de-DE`; a bad one aborts the upload) · `name`/`subtitle` on
|
|
166
|
+
AppInfo vs `description`/`keywords`/`promo` on the version · macOS is a separate platform · list endpoints
|
|
167
|
+
return sparse/empty text (read each localization by id) · primary locale must be populated · App Privacy is
|
|
168
|
+
on the `iris` host (JWT 401s — UI only) · accessibility can't publish until live (409) · edit-version /
|
|
169
|
+
app-info / deliver all break at READY_FOR_REVIEW (fetch by id; native PATCH still works) ·
|
|
170
|
+
screenshots/IAP images must be RGB no-alpha · IAP has two image slots (tall review screenshot vs 1024²
|
|
171
|
+
promo) · char limits (name/subtitle 30, keywords 100, promo 170; IAP name 30 / desc 45; Play title 30 /
|
|
172
|
+
short 80 / full 4000) · **Play uses its OWN locale codes** · Play images push only when the local file
|
|
173
|
+
exists, so a missing set never wipes the live one · Apple requires `name` when *creating* an app-info
|
|
174
|
+
localization (409 otherwise).
|
package/bin/vydanne.mjs
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { loadConfig } from "../src/config.mjs";
|
|
4
|
+
import { Client } from "../src/client.mjs";
|
|
5
|
+
import { COMMANDS, PLAY_COMMANDS } from "../src/registry.mjs";
|
|
6
|
+
|
|
7
|
+
const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url))).version;
|
|
8
|
+
|
|
9
|
+
const argv = process.argv.slice(2);
|
|
10
|
+
const cmd = argv.shift();
|
|
11
|
+
const i = argv.indexOf("--config");
|
|
12
|
+
const cfgPath = i >= 0 ? argv[i + 1] : undefined;
|
|
13
|
+
const si = argv.indexOf("--store");
|
|
14
|
+
const store = si >= 0 ? argv[si + 1] : "apple";
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
if (["version", "-v", "--version"].includes(cmd)) {
|
|
18
|
+
console.log(`vydanne ${VERSION}`);
|
|
19
|
+
} else if (cmd === "locales") {
|
|
20
|
+
const r = (await loadConfig(cfgPath)).resolvedLocales;
|
|
21
|
+
console.log(`supported (${Object.keys(r.supported).length}):`);
|
|
22
|
+
for (const [ui, asc] of Object.entries(r.supported)) console.log(` ${ui} -> ${asc}`);
|
|
23
|
+
console.log(`unsupported (${r.unsupported.length}) [no App Store language -> fall back to primary]: ${r.unsupported.join(", ")}`);
|
|
24
|
+
} else if (store === "google") {
|
|
25
|
+
const cfg = await loadConfig(cfgPath);
|
|
26
|
+
if (!cfg.google) throw new Error("vydanne: no `google` block in config — add packageName + a service-account key");
|
|
27
|
+
if (!PLAY_COMMANDS[cmd]) throw new Error(`vydanne: '${cmd}' isn't available for --store google (try: ${Object.keys(PLAY_COMMANDS).join(", ")})`);
|
|
28
|
+
if (!cfg.google.serviceAccountKey) throw new Error("vydanne: set PLAY_JSON_KEY_FILE (or google.serviceAccountKey) to the Play service-account JSON");
|
|
29
|
+
const { PlayClient } = await import("../src/play/client.mjs");
|
|
30
|
+
const client = await PlayClient.create({ keyPath: cfg.google.serviceAccountKey, packageName: cfg.google.packageName });
|
|
31
|
+
const { run } = await import(`../src/play/commands/${PLAY_COMMANDS[cmd].mod}.mjs`);
|
|
32
|
+
const ok = await run(cfg, client);
|
|
33
|
+
if (ok === false) process.exit(1);
|
|
34
|
+
} else if (COMMANDS[cmd]) {
|
|
35
|
+
const cfg = await loadConfig(cfgPath);
|
|
36
|
+
const spec = COMMANDS[cmd];
|
|
37
|
+
const { run } = await import(`../src/commands/${spec.mod}.mjs`);
|
|
38
|
+
const client = spec.client ? new Client({ keyId: cfg.keyId, issuerId: cfg.issuerId }) : null;
|
|
39
|
+
const ok = await run(cfg, client);
|
|
40
|
+
if (ok === false) process.exit(1);
|
|
41
|
+
} else {
|
|
42
|
+
console.error(usage());
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
} catch (e) {
|
|
46
|
+
console.error(`\x1b[31m${e.message}\x1b[0m`);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function usage() {
|
|
51
|
+
return `vydanne ${VERSION} — App Store Connect submission prep (companion to zdymak). Never submits.
|
|
52
|
+
usage: vydanne <command> [--config vydanne.config.mjs]
|
|
53
|
+
fill metadata + screenshots + previews (native; iOS & macOS separate)
|
|
54
|
+
age-rating set the age rating (AppInfo declaration)
|
|
55
|
+
review-contact App Review contact from the gitignored files
|
|
56
|
+
accessibility Accessibility Nutrition Labels (draft; VYDANNE_A11Y_PUBLISH=1 to publish once live)
|
|
57
|
+
privacy write the record + print the ASC-UI answers (API can't reach iris)
|
|
58
|
+
previews upload App Preview videos (native chunked upload)
|
|
59
|
+
iap validate IAP fields; VYDANNE_FLATTEN=<png> flattens a screenshot to RGB
|
|
60
|
+
compliance generate the US encryption self-classification PDF
|
|
61
|
+
inspect read-only ASC state
|
|
62
|
+
diff show what differs between local (metadata/screenshots/previews) and ASC
|
|
63
|
+
preflight verify submission-completeness (the gotcha checker)
|
|
64
|
+
locales UI -> ASC locale mapping + unsupported
|
|
65
|
+
toggles: VYDANNE_SKIP_METADATA / VYDANNE_SKIP_SCREENSHOTS (fill), VYDANNE_A11Y_PUBLISH (accessibility)`;
|
|
66
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "vydanne",
|
|
3
|
+
"version": "0.2.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).",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"app-store-connect",
|
|
7
|
+
"google-play",
|
|
8
|
+
"aso",
|
|
9
|
+
"ios",
|
|
10
|
+
"android",
|
|
11
|
+
"app-store",
|
|
12
|
+
"play-developer-api",
|
|
13
|
+
"fastlane-alternative",
|
|
14
|
+
"app-metadata",
|
|
15
|
+
"screenshots",
|
|
16
|
+
"app-preview",
|
|
17
|
+
"publishing"
|
|
18
|
+
],
|
|
19
|
+
"type": "module",
|
|
20
|
+
"main": "src/index.mjs",
|
|
21
|
+
"types": "./types/index.d.ts",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./types/index.d.ts",
|
|
25
|
+
"default": "./src/index.mjs"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"bin": {
|
|
29
|
+
"vydanne": "bin/vydanne.mjs"
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=20.9.0"
|
|
33
|
+
},
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"author": "Lonli-Lokli <lonli.lokli@gmail.com>",
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/Lonli-Lokli/vydanne.git"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/Lonli-Lokli/vydanne#readme",
|
|
41
|
+
"bugs": "https://github.com/Lonli-Lokli/vydanne/issues",
|
|
42
|
+
"files": [
|
|
43
|
+
"bin",
|
|
44
|
+
"src",
|
|
45
|
+
"types/index.d.ts",
|
|
46
|
+
"SKILL.md",
|
|
47
|
+
"vydanne.config.example.mjs",
|
|
48
|
+
"README.md",
|
|
49
|
+
"GETTING_STARTED.md"
|
|
50
|
+
],
|
|
51
|
+
"scripts": {
|
|
52
|
+
"check:docs": "node scripts/check-docs.mjs",
|
|
53
|
+
"check:types": "tsc --noEmit -p tsconfig.json && node scripts/check-types.mjs",
|
|
54
|
+
"prepublishOnly": "npm run check:docs && npm run check:types",
|
|
55
|
+
"release:patch": "node scripts/release.mjs patch",
|
|
56
|
+
"release:minor": "node scripts/release.mjs minor",
|
|
57
|
+
"release:major": "node scripts/release.mjs major"
|
|
58
|
+
},
|
|
59
|
+
"dependencies": {
|
|
60
|
+
"pdfkit": "^0.15.0",
|
|
61
|
+
"sharp": "^0.35.3"
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"typescript": "^5.6.0"
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/client.mjs
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { makeToken } from "./jwt.mjs";
|
|
2
|
+
|
|
3
|
+
const API = "https://api.appstoreconnect.apple.com";
|
|
4
|
+
const IRIS = "https://appstoreconnect.apple.com/iris";
|
|
5
|
+
const DEAD_VERSION = ["READY_FOR_SALE", "REMOVED_FROM_SALE", "REPLACED_WITH_NEW_VERSION"];
|
|
6
|
+
const DEAD_INFO = ["READY_FOR_SALE", "REPLACED_WITH_NEW_VERSION", "REMOVED_FROM_SALE"];
|
|
7
|
+
|
|
8
|
+
// Thin ASC REST client. Encodes the gotchas: `iris` host (App Privacy 401s the JWT), version + app-info
|
|
9
|
+
// fetched from the FULL list (get_edit filters out READY_FOR_REVIEW), and individual localization reads
|
|
10
|
+
// (list endpoints return sparse/empty text).
|
|
11
|
+
export class Client {
|
|
12
|
+
constructor({ keyId, issuerId }) {
|
|
13
|
+
this.token = makeToken({ keyId, issuerId });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async req(method, urlPath, { iris = false, body, rawHeaders, rawBody } = {}) {
|
|
17
|
+
const headers = { Authorization: `Bearer ${this.token}` };
|
|
18
|
+
if (body) headers["Content-Type"] = "application/json";
|
|
19
|
+
Object.assign(headers, rawHeaders || {});
|
|
20
|
+
const res = await fetch(`${iris ? IRIS : API}${urlPath}`, {
|
|
21
|
+
method, headers, body: rawBody ?? (body ? JSON.stringify(body) : undefined),
|
|
22
|
+
});
|
|
23
|
+
const text = await res.text();
|
|
24
|
+
let json;
|
|
25
|
+
try { json = text ? JSON.parse(text) : {}; } catch { json = text; }
|
|
26
|
+
return { status: res.status, json, text };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get(p, opts) { return this.req("GET", p, opts); }
|
|
30
|
+
post(p, body) { return this.req("POST", p, { body }); }
|
|
31
|
+
patch(p, body) { return this.req("PATCH", p, { body }); }
|
|
32
|
+
del(p) { return this.req("DELETE", p); }
|
|
33
|
+
|
|
34
|
+
async findApp(bundleId) {
|
|
35
|
+
const { json } = await this.get(`/v1/apps?filter[bundleId]=${bundleId}&limit=1`);
|
|
36
|
+
const app = json.data?.[0];
|
|
37
|
+
if (!app) throw new Error(`vydanne: app '${bundleId}' not found for this ASC key`);
|
|
38
|
+
this.app = app; this.appId = app.id;
|
|
39
|
+
return app;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async editVersion(platform) {
|
|
43
|
+
const { json } = await this.get(`/v1/apps/${this.appId}/appStoreVersions?filter[platform]=${platform}&limit=10`);
|
|
44
|
+
const data = json.data || [];
|
|
45
|
+
return data.find((v) => !DEAD_VERSION.includes(v.attributes.appStoreState)) || data[0] || null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async appInfo() {
|
|
49
|
+
const { json } = await this.get(`/v1/apps/${this.appId}/appInfos?limit=10`);
|
|
50
|
+
const data = json.data || [];
|
|
51
|
+
return data.find((i) => !DEAD_INFO.includes(i.attributes.state)) || data[0] || null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async versionLocalizations(versionId) {
|
|
55
|
+
const { json } = await this.get(`/v1/appStoreVersions/${versionId}/appStoreVersionLocalizations?limit=200`);
|
|
56
|
+
return json.data || [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Individual fetch — the list endpoints omit the text fields (sparse), so a populated/empty check must
|
|
60
|
+
// read each localization by id.
|
|
61
|
+
async localization(id, kind = "appStoreVersionLocalizations") {
|
|
62
|
+
const { json } = await this.get(`/v1/${kind}/${id}`);
|
|
63
|
+
return json.data?.attributes || {};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { green, yellow, red } from "../util.mjs";
|
|
2
|
+
|
|
3
|
+
const T = true, F = false;
|
|
4
|
+
// Honest DoD-backed matrix; Apple caveats: Larger Text N/A on Mac, Voice Control N/A on Watch, no video →
|
|
5
|
+
// no captions/AD. DRAFT-safe; VYDANNE_A11Y_PUBLISH=1 publishes, but Apple 409s publish until the app is live.
|
|
6
|
+
const MATRIX = {
|
|
7
|
+
IPHONE: { supportsVoiceover: T, supportsVoiceControl: T, supportsLargerText: T, supportsSufficientContrast: T, supportsDarkInterface: T, supportsDifferentiateWithoutColorAlone: T, supportsReducedMotion: T, supportsCaptions: F, supportsAudioDescriptions: F },
|
|
8
|
+
IPAD: { supportsVoiceover: T, supportsVoiceControl: T, supportsLargerText: T, supportsSufficientContrast: T, supportsDarkInterface: T, supportsDifferentiateWithoutColorAlone: T, supportsReducedMotion: T, supportsCaptions: F, supportsAudioDescriptions: F },
|
|
9
|
+
MAC: { supportsVoiceover: T, supportsVoiceControl: T, supportsSufficientContrast: T, supportsDarkInterface: T, supportsDifferentiateWithoutColorAlone: T, supportsReducedMotion: T, supportsCaptions: F, supportsAudioDescriptions: F },
|
|
10
|
+
APPLE_WATCH: { supportsVoiceover: T, supportsLargerText: T, supportsSufficientContrast: T, supportsDarkInterface: T, supportsDifferentiateWithoutColorAlone: T, supportsReducedMotion: T, supportsCaptions: F, supportsAudioDescriptions: F },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export async function run(config, client) {
|
|
14
|
+
await client.findApp(config.bundleId);
|
|
15
|
+
const publish = process.env.VYDANNE_A11Y_PUBLISH === "1";
|
|
16
|
+
const { json } = await client.get(`/v1/apps/${client.appId}/accessibilityDeclarations?limit=50`);
|
|
17
|
+
const decls = {};
|
|
18
|
+
for (const d of json.data || []) decls[d.attributes.deviceFamily] = d.id;
|
|
19
|
+
let gated = false;
|
|
20
|
+
for (const [fam, attributes] of Object.entries(MATRIX)) {
|
|
21
|
+
const id = decls[fam];
|
|
22
|
+
if (!id) { console.error(yellow(` no ${fam} declaration`)); continue; }
|
|
23
|
+
const r = await client.patch(`/v1/accessibilityDeclarations/${id}`, { data: { type: "accessibilityDeclarations", id, attributes } });
|
|
24
|
+
if (r.status >= 300) { console.error(red(` ${fam} draft ${r.status}`)); continue; }
|
|
25
|
+
if (publish) {
|
|
26
|
+
const p = await client.patch(`/v1/accessibilityDeclarations/${id}`, { data: { type: "accessibilityDeclarations", id, attributes: { publish: true } } });
|
|
27
|
+
if (p.status < 300) console.log(green(` ${fam}: PUBLISHED`));
|
|
28
|
+
else if (JSON.stringify(p.json).includes("CANNOT_PUBLISH_APP_MUST_BE_AVAILABLE")) { gated = true; console.log(yellow(` ${fam}: draft saved — publish deferred (app not live yet)`)); }
|
|
29
|
+
else console.error(red(` ${fam} publish ${p.status}`));
|
|
30
|
+
} else {
|
|
31
|
+
console.log(green(` ${fam}: draft saved`));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
console.log(gated ? yellow("accessibility staged (DRAFT); re-run with VYDANNE_A11Y_PUBLISH=1 once the app is live") : "accessibility done");
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { green, yellow, red } from "../util.mjs";
|
|
2
|
+
|
|
3
|
+
// Set the age rating via the AppInfo age-rating declaration. v1: "4+" — every content descriptor NONE.
|
|
4
|
+
// (PATCH is partial; Apple recomputes 4+ from these.) app-info fetched from the full list (survives
|
|
5
|
+
// READY_FOR_REVIEW).
|
|
6
|
+
export async function run(config, client) {
|
|
7
|
+
if (config.rating !== "4+") { console.error(yellow(`age-rating: only '4+' (all-NONE) implemented; config=${config.rating}`)); return false; }
|
|
8
|
+
await client.findApp(config.bundleId);
|
|
9
|
+
const info = await client.appInfo();
|
|
10
|
+
if (!info) { console.error(red("age-rating: no editable app info")); return false; }
|
|
11
|
+
const { json } = await client.get(`/v1/appInfos/${info.id}/ageRatingDeclaration`);
|
|
12
|
+
const id = json.data?.id;
|
|
13
|
+
if (!id) { console.error(red("age-rating: no declaration")); return false; }
|
|
14
|
+
const N = "NONE";
|
|
15
|
+
// Apple's 2025 age-rating schema. A PATCH must include ALL required fields (a partial set 409s),
|
|
16
|
+
// and `ageRatingOverride` (deprecated) cannot be sent alongside `ageRatingOverrideV2` — so we send
|
|
17
|
+
// only V2. Content descriptors are enums (NONE); capability questions are booleans (false). All
|
|
18
|
+
// benign here → 4+.
|
|
19
|
+
const attributes = {
|
|
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
|
+
};
|
|
29
|
+
const r = await client.patch(`/v1/ageRatingDeclarations/${id}`, { data: { type: "ageRatingDeclarations", id, attributes } });
|
|
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+"));
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import PDFDocument from "pdfkit";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { green, yellow } from "../util.mjs";
|
|
4
|
+
|
|
5
|
+
// Generate the US encryption self-classification report PDF (ECCN 5D002, License Exception ENC 740.17(b)(1))
|
|
6
|
+
// for standard-crypto apps — the document for the ASC "App Encryption Documentation" US slot. France = a
|
|
7
|
+
// separate ANSSI territory upload; US needs no CCATS. Native pdfkit (no python).
|
|
8
|
+
export async function run(config) {
|
|
9
|
+
const e = config.export || {};
|
|
10
|
+
if ((e.encryption || "standard") !== "standard") { console.log(yellow("export.encryption != standard — nothing to self-classify")); return true; }
|
|
11
|
+
const appName = e.appName || config.bundleId.split(".").pop();
|
|
12
|
+
const out = `export-compliance/${config.bundleId}-US-encryption-self-classification.pdf`;
|
|
13
|
+
fs.mkdirSync("export-compliance", { recursive: true });
|
|
14
|
+
|
|
15
|
+
await new Promise((resolve, reject) => {
|
|
16
|
+
const doc = new PDFDocument({ size: "A4", margin: 56 });
|
|
17
|
+
const stream = fs.createWriteStream(out);
|
|
18
|
+
doc.pipe(stream);
|
|
19
|
+
const kv = (k, v) => { doc.font("Helvetica-Bold").fontSize(10.5).fillColor("#0b0b0a").text(k, { continued: true }); doc.font("Helvetica").fillColor("#1e1e1e").text(" " + v); };
|
|
20
|
+
const h = (t) => { doc.moveDown(0.6).font("Helvetica-Bold").fontSize(13).fillColor("#0b0b0a").text(t); doc.moveDown(0.2); };
|
|
21
|
+
const p = (t) => { doc.font("Helvetica").fontSize(10.5).fillColor("#1e1e1e").text(t); };
|
|
22
|
+
|
|
23
|
+
doc.font("Helvetica-Bold").fontSize(18).fillColor("#0b0b0a").text("Encryption Export Self-Classification Report");
|
|
24
|
+
doc.moveTo(56, doc.y + 4).lineTo(539, doc.y + 4).strokeColor("#c8c8c8").stroke();
|
|
25
|
+
doc.moveDown();
|
|
26
|
+
kv("Product:", appName); kv("Bundle ID:", config.bundleId); kv("Version:", String(e.version || "1.0")); kv("Developer (Apple Team ID):", String(e.teamId || "-"));
|
|
27
|
+
h("Classification");
|
|
28
|
+
kv("ECCN:", "5D002 (encryption 'software')"); kv("Authorization:", "License Exception ENC, EAR 740.17(b)(1)"); kv("Basis:", "Mass-market, self-classified (Note 3 to Cat. 5 Part 2)");
|
|
29
|
+
h("Cryptography inventory");
|
|
30
|
+
p("The application uses only standard, published cryptographic algorithms (NIST / IETF). No proprietary or non-standard cryptography is implemented. On Apple platforms the primitives resolve to Apple CryptoKit.");
|
|
31
|
+
doc.moveDown(0.4);
|
|
32
|
+
for (const [purpose, alg, ks] of [["Content & media confidentiality", "AES-GCM (AEAD)", "256-bit"], ["Key derivation / addressing", "HMAC-SHA256", "256-bit"], ["Token signing", "Ed25519", "255-bit curve"], ["Transport", "TLS 1.2 / 1.3", "standard"]]) {
|
|
33
|
+
doc.font("Helvetica").fontSize(10.5).fillColor("#1e1e1e").text(`• ${purpose} — ${alg} (${ks})`);
|
|
34
|
+
}
|
|
35
|
+
h("Statement");
|
|
36
|
+
p("The product provides general data-confidentiality end-to-end encryption of the user's own data for optional cross-device sync. It is a mass-market consumer application distributed through public app stores, uses only standard published algorithms, and qualifies for export under License Exception ENC, EAR 740.17(b)(1), ECCN 5D002. A self-classification report has been submitted to BIS (crypt-supp8@bis.doc.gov) and the NSA (enc@nsa.gov).");
|
|
37
|
+
doc.end();
|
|
38
|
+
stream.on("finish", resolve);
|
|
39
|
+
stream.on("error", reject);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
console.log(green(`wrote ${out}`));
|
|
43
|
+
if (e.france) console.log(yellow("France: file + upload the ANSSI declaration too (territory rule). US: email BIS+NSA, no CCATS."));
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { green, yellow, red } from "../util.mjs";
|
|
4
|
+
import { VALID } from "../locales.mjs";
|
|
5
|
+
|
|
6
|
+
// [asc attribute, local metadata filename, isLongText]
|
|
7
|
+
const VERSION_FIELDS = [
|
|
8
|
+
["description", "description", true], ["keywords", "keywords", false], ["promotionalText", "promotional_text", false],
|
|
9
|
+
["whatsNew", "release_notes", true], ["marketingUrl", "marketing_url", false], ["supportUrl", "support_url", false],
|
|
10
|
+
];
|
|
11
|
+
const INFO_FIELDS = [["name", "name", false], ["subtitle", "subtitle", false]];
|
|
12
|
+
const IOS_DEVICE = { iphone69: "APP_IPHONE_67", iphone65: "APP_IPHONE_65", ipad13: "APP_IPAD_PRO_3GEN_129", watch: "APP_WATCH_ULTRA" };
|
|
13
|
+
const MAC_DEVICE = { macos: "APP_DESKTOP" };
|
|
14
|
+
|
|
15
|
+
const norm = (s) => (s == null ? null : String(s).replace(/\r/g, "").replace(/\n+$/, "").trim());
|
|
16
|
+
const short = (s, n = 24) => { s = String(s).replace(/\n/g, " "); return s.length > n ? s.slice(0, n) + "…" : s; };
|
|
17
|
+
|
|
18
|
+
// Show what's different between the local sources (metadata folders, screenshots, previews) and App Store
|
|
19
|
+
// Connect — i.e. what `fill` / `previews` would change. Reads each localization by id (list is sparse).
|
|
20
|
+
export async function run(config, client) {
|
|
21
|
+
await client.findApp(config.bundleId);
|
|
22
|
+
const info = await client.appInfo();
|
|
23
|
+
const infoLocs = info ? (await client.get(`/v1/appInfos/${info.id}/appInfoLocalizations?limit=200`)).json.data || [] : [];
|
|
24
|
+
let actionable = 0; // differences `fill`/`previews` would actually change (release_notes on a 1.0 is benign)
|
|
25
|
+
|
|
26
|
+
for (const platform of config.platforms) {
|
|
27
|
+
const v = await client.editVersion(platform);
|
|
28
|
+
if (!v) { console.log(red(`${platform}: no editable version`)); continue; }
|
|
29
|
+
console.log(`${platform} v${v.attributes.versionString} ${v.attributes.appStoreState}`);
|
|
30
|
+
const verLocs = await client.versionLocalizations(v.id);
|
|
31
|
+
const localDirs = fs.existsSync(config.metadataDir)
|
|
32
|
+
? fs.readdirSync(config.metadataDir, { withFileTypes: true }).filter((d) => d.isDirectory() && VALID.has(d.name)).map((d) => d.name)
|
|
33
|
+
: [];
|
|
34
|
+
|
|
35
|
+
const presence = {}; // field -> { localOnly:[], remoteOnly:[] } (systematic — aggregated, not per-locale)
|
|
36
|
+
for (const code of localDirs) {
|
|
37
|
+
const folder = path.join(config.metadataDir, code);
|
|
38
|
+
const readL = (f) => { const p = path.join(folder, `${f}.txt`); return fs.existsSync(p) ? norm(fs.readFileSync(p, "utf8")) : null; };
|
|
39
|
+
const vl = verLocs.find((l) => l.attributes.locale === code);
|
|
40
|
+
const il = infoLocs.find((l) => l.attributes.locale === code);
|
|
41
|
+
const vAttrs = vl ? await client.localization(vl.id) : {};
|
|
42
|
+
const iAttrs = il ? await client.localization(il.id, "appInfoLocalizations") : {};
|
|
43
|
+
const content = []; // per-locale value differences (the ones you usually care about)
|
|
44
|
+
const record = (file, L, R, long) => {
|
|
45
|
+
const c = cmp(L, R, long);
|
|
46
|
+
if (!c) return;
|
|
47
|
+
if (c.kind === "diff") { content.push(`${file} ${red("differs")} ${c.detail}`); actionable++; return; }
|
|
48
|
+
const arr = (presence[file] ||= { localOnly: [], remoteOnly: [] });
|
|
49
|
+
if (c.kind === "local-only") { arr.localOnly.push(code); if (file !== "release_notes") actionable++; }
|
|
50
|
+
else { arr.remoteOnly.push(code); actionable++; }
|
|
51
|
+
};
|
|
52
|
+
if (!vl) { content.unshift(yellow("[fill would create localization]")); actionable++; }
|
|
53
|
+
for (const [attr, file, long] of VERSION_FIELDS) record(file, readL(file), norm(vAttrs[attr]), long);
|
|
54
|
+
for (const [attr, file, long] of INFO_FIELDS) record(file, readL(file), norm(iAttrs[attr]), long);
|
|
55
|
+
if (content.length) console.log(` ${code}: ${content.join(" · ")}`);
|
|
56
|
+
}
|
|
57
|
+
for (const [file, p] of Object.entries(presence)) {
|
|
58
|
+
const hint = file === "release_notes" ? " (What's New — N/A on a first version)" : "";
|
|
59
|
+
if (p.localOnly.length) console.log(` ${green(`${file} local-only`)} in ${p.localOnly.length} locale(s)${hint}`);
|
|
60
|
+
if (p.remoteOnly.length) console.log(` ${yellow(`${file} remote-only`)} in ${p.remoteOnly.length} locale(s)`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const localSet = new Set(localDirs);
|
|
64
|
+
const extra = verLocs.map((l) => l.attributes.locale).filter((c) => !localSet.has(c));
|
|
65
|
+
if (extra.length) console.log(` ${yellow("remote-only locales")} (no local folder): ${extra.join(", ")}`);
|
|
66
|
+
|
|
67
|
+
actionable += await mediaDiff(config, client, platform, verLocs);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
console.log();
|
|
71
|
+
console.log(actionable ? yellow(`${actionable} actionable difference(s) — run \`fill\` / \`previews\` to sync`) : green("in sync — local matches App Store Connect"));
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function cmp(L, R, long) {
|
|
76
|
+
if (L == null && (R == null || R === "")) return null;
|
|
77
|
+
if (L == null) return { kind: "remote-only" };
|
|
78
|
+
if (R == null || R === "") return { kind: "local-only" };
|
|
79
|
+
if (L === R) return null;
|
|
80
|
+
return { kind: "diff", detail: long ? `(local ${L.length} / remote ${R.length} ch)` : `local="${short(L)}" remote="${short(R)}"` };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function mediaDiff(config, client, platform, verLocs) {
|
|
84
|
+
const primary = verLocs.find((l) => l.attributes.locale === config.primaryLocale);
|
|
85
|
+
if (!primary) return 0;
|
|
86
|
+
let diffs = 0;
|
|
87
|
+
const dev = platform === "MAC_OS" ? MAC_DEVICE : IOS_DEVICE;
|
|
88
|
+
const base = platform === "MAC_OS" ? "fastlane/screenshots-macos" : "fastlane/screenshots";
|
|
89
|
+
const localDir = path.join(base, config.primaryLocale);
|
|
90
|
+
const local = {};
|
|
91
|
+
if (fs.existsSync(localDir)) for (const f of fs.readdirSync(localDir).filter((f) => f.endsWith(".png"))) { const dt = dev[f.split("_")[0]]; if (dt) local[dt] = (local[dt] || 0) + 1; }
|
|
92
|
+
const { json: sets } = await client.get(`/v1/appStoreVersionLocalizations/${primary.id}/appScreenshotSets?include=appScreenshots&limit=50`);
|
|
93
|
+
const remote = {};
|
|
94
|
+
for (const s of sets.data || []) remote[s.attributes.screenshotDisplayType] = (s.relationships?.appScreenshots?.data || []).length;
|
|
95
|
+
for (const dt of new Set([...Object.keys(local), ...Object.keys(remote)])) {
|
|
96
|
+
const L = local[dt] || 0, R = remote[dt] || 0;
|
|
97
|
+
if (L !== R) { diffs++; console.log(` ${yellow("screenshots")} ${dt.replace("APP_", "")}: local ${L} / remote ${R} (@${config.primaryLocale})`); }
|
|
98
|
+
}
|
|
99
|
+
const { json: psets } = await client.get(`/v1/appStoreVersionLocalizations/${primary.id}/appPreviewSets?include=appPreviews&limit=50`);
|
|
100
|
+
const remotePrev = {};
|
|
101
|
+
for (const s of psets.data || []) remotePrev[s.attributes.previewType] = (s.relationships?.appPreviews?.data || []).length;
|
|
102
|
+
for (const spec of (config.previews || []).filter((s) => s.platform === platform && (s.locales || []).includes(config.primaryLocale))) {
|
|
103
|
+
const L = fs.existsSync(path.resolve(spec.file)) ? 1 : 0, R = remotePrev[spec.type] || 0;
|
|
104
|
+
if (L !== R) { diffs++; console.log(` ${yellow("preview")} ${spec.type}: local ${L} / remote ${R} (@${config.primaryLocale})`); }
|
|
105
|
+
}
|
|
106
|
+
return diffs;
|
|
107
|
+
}
|