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.
@@ -0,0 +1,124 @@
1
+ import { green, yellow, red } from "../util.mjs";
2
+
3
+ /**
4
+ * The two app-level facts that block **Add for Review** and nothing else in vydanne could set.
5
+ *
6
+ * You must select a primary category for your app.
7
+ * You must set up Content Rights Information in App Information.
8
+ *
9
+ * Both are one API call each and were simply never wired, so a release that was green everywhere —
10
+ * metadata filled, screenshots uploaded, build attached, preflight clean — still stopped dead at the
11
+ * last screen with two errors nobody could act on from a terminal. That is the worst place to
12
+ * discover missing automation: after the work, in a web form, with no record of what the right
13
+ * answer was last time.
14
+ *
15
+ * Deliberately its OWN command rather than folded into `prepare`. `prepare` is about a version; these
16
+ * are about the app, and they outlive every version — setting them once is normal and re-running is a
17
+ * no-op. Keeping them separate also means a failure here names itself instead of failing a step whose
18
+ * name says "version".
19
+ */
20
+
21
+ /**
22
+ * Apple's category vocabulary is a fixed set of ids, and `GAMES` is the only one with subcategories.
23
+ * Listed rather than free-typed because a wrong id returns a 409 that names neither the field nor the
24
+ * legal values, and the guess someone makes from that ("Puzzle", "games") is wrong twice over — the
25
+ * ids are upper snake case and game subcategories are prefixed.
26
+ */
27
+ const CATEGORIES = new Set([
28
+ "BOOKS", "BUSINESS", "DEVELOPER_TOOLS", "EDUCATION", "ENTERTAINMENT", "FINANCE", "FOOD_AND_DRINK",
29
+ "GAMES", "GRAPHICS_AND_DESIGN", "HEALTH_AND_FITNESS", "LIFESTYLE", "MAGAZINES_AND_NEWSPAPERS",
30
+ "MEDICAL", "MUSIC", "NAVIGATION", "NEWS", "PHOTO_AND_VIDEO", "PRODUCTIVITY", "REFERENCE",
31
+ "SHOPPING", "SOCIAL_NETWORKING", "SPORTS", "TRAVEL", "UTILITIES", "WEATHER",
32
+ ]);
33
+
34
+ const GAME_SUBCATEGORIES = new Set([
35
+ "GAMES_ACTION", "GAMES_ADVENTURE", "GAMES_BOARD", "GAMES_CARD", "GAMES_CASINO", "GAMES_CASUAL",
36
+ "GAMES_FAMILY", "GAMES_MUSIC", "GAMES_PUZZLE", "GAMES_RACING", "GAMES_ROLE_PLAYING",
37
+ "GAMES_SIMULATION", "GAMES_SPORTS", "GAMES_STRATEGY", "GAMES_TRIVIA", "GAMES_WORD",
38
+ ]);
39
+
40
+ /** Apple's own two values, stated as a question so a config cannot get the polarity backwards. */
41
+ const RIGHTS = {
42
+ true: "USES_THIRD_PARTY_CONTENT",
43
+ false: "DOES_NOT_USE_THIRD_PARTY_CONTENT",
44
+ };
45
+
46
+ function validate(config) {
47
+ const c = config.categories;
48
+ if (!c) return { problem: "appinfo: no `categories` in the config — set at least { primary: 'GAMES' }." };
49
+ const bad = [];
50
+ if (!CATEGORIES.has(c.primary)) bad.push(`primary '${c.primary}' is not an Apple category id (e.g. GAMES, PUZZLE is a SUBcategory)`);
51
+ if (c.secondary && !CATEGORIES.has(c.secondary)) bad.push(`secondary '${c.secondary}' is not an Apple category id`);
52
+ for (const k of ["primarySubcategoryOne", "primarySubcategoryTwo", "secondarySubcategoryOne", "secondarySubcategoryTwo"]) {
53
+ if (c[k] && !GAME_SUBCATEGORIES.has(c[k])) bad.push(`${k} '${c[k]}' is not a game subcategory id (they are GAMES_*)`);
54
+ }
55
+ // Apple allows subcategories only under GAMES; sending them otherwise is a 409 that reads as a
56
+ // generic relationship error.
57
+ if (c.primarySubcategoryOne && c.primary !== "GAMES") bad.push("subcategories are only valid when primary is GAMES");
58
+ if (config.contentRights !== undefined && typeof config.contentRights !== "boolean") {
59
+ bad.push("contentRights must be true (uses third-party content) or false (does not)");
60
+ }
61
+ return bad.length ? { problem: `appinfo: ${bad.join("; ")}` } : {};
62
+ }
63
+
64
+ const rel = (id) => (id ? { data: { type: "appCategories", id } } : { data: null });
65
+
66
+ export async function run(config, client) {
67
+ const { problem } = validate(config);
68
+ if (problem) { console.error(red(problem)); return false; }
69
+
70
+ await client.findApp(config.bundleId);
71
+ // Same refusal as age-rating, and for the same reason: the LIVE app info's category is not ours to
72
+ // aim a write at, and appInfo() returning the live record instead of null once made that write look
73
+ // like it had worked.
74
+ const info = await client.appInfo();
75
+ if (!info) {
76
+ console.error(red("appinfo: no editable app info — refusing to write to the live record."));
77
+ console.error(" `vydanne prepare --apply` starts the next version, which makes app info editable again.");
78
+ return false;
79
+ }
80
+
81
+ const c = config.categories;
82
+ const relationships = {
83
+ primaryCategory: rel(c.primary),
84
+ primarySubcategoryOne: rel(c.primarySubcategoryOne),
85
+ primarySubcategoryTwo: rel(c.primarySubcategoryTwo),
86
+ secondaryCategory: rel(c.secondary),
87
+ secondarySubcategoryOne: rel(c.secondarySubcategoryOne),
88
+ secondarySubcategoryTwo: rel(c.secondarySubcategoryTwo),
89
+ };
90
+ const shown = [c.primary, c.primarySubcategoryOne, c.primarySubcategoryTwo, c.secondary]
91
+ .filter(Boolean).join(" · ");
92
+ console.log(` categories: ${shown}`);
93
+
94
+ const r = await client.patch(`/v1/appInfos/${info.id}`, {
95
+ data: { type: "appInfos", id: info.id, relationships },
96
+ });
97
+ if (r.status >= 300) {
98
+ console.error(red(`appinfo: categories ${r.status}`));
99
+ for (const e of r.json?.errors || []) console.error(` ${e.title}: ${e.detail}`);
100
+ return false;
101
+ }
102
+ console.log(client.dryRun ? yellow(` WOULD set categories`) : green(` categories set`));
103
+
104
+ // Content rights is on the APP, not the app info — a different resource, which is most of why it
105
+ // was missed. Skipped entirely when undeclared, so an app that has already answered in the UI is
106
+ // not overwritten by a default nobody chose.
107
+ if (config.contentRights === undefined) {
108
+ console.log(yellow(" contentRights not declared — Add for Review needs it; set `contentRights: false` if the app uses no third-party content."));
109
+ return true;
110
+ }
111
+ const declaration = RIGHTS[String(config.contentRights)];
112
+ const a = await client.patch(`/v1/apps/${client.appId}`, {
113
+ data: { type: "apps", id: client.appId, attributes: { contentRightsDeclaration: declaration } },
114
+ });
115
+ if (a.status >= 300) {
116
+ console.error(red(`appinfo: content rights ${a.status}`));
117
+ for (const e of a.json?.errors || []) console.error(` ${e.title}: ${e.detail}`);
118
+ return false;
119
+ }
120
+ console.log(client.dryRun
121
+ ? yellow(` WOULD set content rights -> ${declaration}`)
122
+ : green(` content rights -> ${declaration}`));
123
+ return true;
124
+ }
@@ -0,0 +1,328 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { green, yellow, red } from "../util.mjs";
5
+ import { toAsc } from "../locales.mjs";
6
+ import { IOS_DEVICE, MAC_DEVICE, screenshotBase, IMAGE_FILE } from "../screenshots.mjs";
7
+ import { playImages } from "../play/images.mjs";
8
+
9
+ /**
10
+ * Map zdymak's build output onto the folders `fill` uploads from.
11
+ *
12
+ * WHY THIS EXISTS. zdymak and vydanne were documented as lining up with "no glue", and they used to:
13
+ * zdymak's default output paths were exactly vydanne's read paths. As of zdymak 0.15.0 they are not.
14
+ * zdymak writes everything under ONE root (`out:` in zdymak.config.mjs, default `./store-assets`)
15
+ * shaped `<locale>/<dir>/NN-name.png`, while vydanne reads several roots in a different shape. Three
16
+ * things differ, so no amount of retuning `out` can fix it:
17
+ *
18
+ * 1. SEVERAL roots (the screenshot bases, the Play image sources) versus one `out`.
19
+ * 2. Locale CODES — zdymak writes `de`, `zh`, `ur`; Apple wants `de-DE`, `zh-Hans`, `ur-PK`.
20
+ * 3. The slot PREFIX — `fill` picks the device slot from the token before the first underscore, so a
21
+ * file must be named `iphone69_…`; zdymak names it `01-fresh.png`.
22
+ *
23
+ * The failure mode is the dangerous one: `zdymak screenshots` reports success for every locale and
24
+ * writes nothing vydanne can see, so `fill` re-uploads whatever was in the screenshots folder from the
25
+ * last time the two tools agreed — silently shipping stale store art. One app shipped exactly that, its
26
+ * screenshots four days older than its own store-assets. So the mapping lives HERE, in the uploader,
27
+ * where the locale table (`toAsc`), the slot tokens (IOS_DEVICE) and the Play read paths (playImages)
28
+ * are the very values `fill` uploads by — imported, not copied, so the bridge cannot drift from the
29
+ * thing it feeds.
30
+ *
31
+ * SOURCE DIRECTORIES ARE NOT TARGET NAMES. zdymak writes each shot to `<dir || target>`, and `dir:` is
32
+ * how one target serves two purposes — a styled `play-phone/` for the website and a plain
33
+ * `play-phone-plain/` for the Play upload, which Google requires to be the bare interface. It is also
34
+ * the only way to produce Play's 7" slot at all: there is no `play-tablet7` target, so that set can
35
+ * only exist as `{ target: 'play-tablet', dir: 'play-tablet7-plain' }`. Mapping by TARGET therefore got
36
+ * two things wrong at once on any config using `dir:` — it bridged the styled variant into the plain
37
+ * slot, and it treated the real 7" directory as a locale while wiping the destination it belonged in.
38
+ * So the tables below are keyed by DIRECTORY, with the `-plain` convention preferred and the bare
39
+ * target name as the fallback, and `bridge.apple` / `bridge.play` in vydanne.config.mjs override both.
40
+ *
41
+ * OWNERSHIP, NOT REBUILD-IF-PRESENT. The destinations are rebuilt from empty so a screenshot removed
42
+ * upstream disappears here too instead of lingering and being uploaded forever — but only for a store
43
+ * the bridge actually produced files for this run. Resetting a root the bridge did not fill would
44
+ * delete the screenshots of someone who uses zdymak for Play and manages Apple's by hand; resetting it
45
+ * only when something happened to land in it is how dropped macOS art survived a form factor being
46
+ * removed from zdymak.config.mjs. Per STORE is the line that satisfies both: any Apple output at all
47
+ * means the bridge owns both Apple roots, including the macOS one it wrote nothing into today.
48
+ *
49
+ * Local files only — nothing touches a store, which is why this command takes no `--apply`; pass
50
+ * `--dry-run` to see exactly what a real run would write and remove.
51
+ */
52
+
53
+ // Filename token that selects an App Store display type -> the zdymak output directory it comes from.
54
+ // The tokens are asserted against IOS_DEVICE/MAC_DEVICE at load: a token `fill` does not understand
55
+ // would make every bridged file silently ignorable, which is the exact class of failure this command
56
+ // exists to close.
57
+ const APPLE_SLOTS = {
58
+ iphone69: "appstore-iphone-6.9",
59
+ iphone65: "appstore-iphone-6.5",
60
+ ipad13: "appstore-ipad-13",
61
+ watch: "appstore-watch",
62
+ };
63
+ /** macOS screenshots live under a different root entirely, so they are carried separately. */
64
+ const MAC_SLOT = { macos: "appstore-mac" };
65
+
66
+ for (const t of Object.keys(APPLE_SLOTS)) {
67
+ if (!(t in IOS_DEVICE)) throw new Error(`bridge: slot token '${t}' unknown to fill — update APPLE_SLOTS`);
68
+ }
69
+ for (const t of Object.keys(MAC_SLOT)) {
70
+ if (!(t in MAC_DEVICE)) throw new Error(`bridge: slot token '${t}' unknown to fill — update MAC_SLOT`);
71
+ }
72
+
73
+ // Play image type -> candidate source directories, in preference order. The `-plain` set is what Google
74
+ // asks for on a store listing ("no additional text, graphics, or backgrounds that are not part of the
75
+ // interface"), so it wins when both exist; the bare target name is what a config with no `dir:` override
76
+ // produces. The Play icon is deliberately absent: it comes from znachok, not zdymak, and is already at
77
+ // its read path.
78
+ const PLAY_SOURCES = {
79
+ phoneScreenshots: ["play-phone-plain", "play-phone"],
80
+ sevenInchScreenshots: ["play-tablet7-plain", "play-tablet7"],
81
+ tenInchScreenshots: ["play-tablet-plain", "play-tablet"],
82
+ wearScreenshots: ["play-wear-plain", "play-wear"],
83
+ tvScreenshots: ["play-tv-plain", "play-tv"],
84
+ featureGraphic: ["play-feature-graphic.png"],
85
+ tvBanner: ["play-tv-banner.png"],
86
+ };
87
+
88
+ // A directory under `out` that is shaped like a locale code. Everything zdymak writes at that level is
89
+ // either an output directory or a locale, and the old rule — "not a known target, therefore a locale" —
90
+ // turned every `dir:` override into a phantom locale (`play-phone-plain`, `appstore-iphone-6.9-dark`)
91
+ // that then failed to resolve and was reported as falling back to the primary listing.
92
+ const LOCALE_SHAPED = /^[a-z]{2,3}(-[A-Za-z]{2,4}|-[0-9]{3})?$/;
93
+
94
+ const dirsIn = (p) => (fs.existsSync(p) ? fs.readdirSync(p, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name) : []);
95
+ const imagesIn = (p) => (fs.existsSync(p) ? fs.readdirSync(p).filter((f) => IMAGE_FILE.test(f)).sort() : []);
96
+
97
+ /**
98
+ * What zdymak is configured to write: its `out`, and every directory name it produces.
99
+ *
100
+ * Read from the app's own zdymak.config.mjs so the two tools cannot disagree about the layout. Absent
101
+ * or unreadable is fine — the directory names are then discovered from disk instead, which is enough
102
+ * for the common case and only loses the ability to tell a `dir:` override apart from a locale by
103
+ * anything other than its shape.
104
+ */
105
+ async function readZdymak(config) {
106
+ const out = { root: config.bridge?.out || "./store-assets", knownDirs: new Set(), fromConfig: false };
107
+ const file = path.resolve("zdymak.config.mjs");
108
+ if (!fs.existsSync(file)) return out;
109
+ let cfg;
110
+ try {
111
+ cfg = (await import(pathToFileURL(file).href)).default;
112
+ } catch (e) {
113
+ // A config that imports `zdymak` in a project where zdymak is not installed is the likeliest cause,
114
+ // and "Cannot find package 'zdymak'" on its own does not suggest a fix.
115
+ console.log(yellow(`bridge: could not read zdymak.config.mjs (${e.message.split("\n")[0]})`));
116
+ console.log(" Falling back to directory names on disk. Install zdymak, or set `bridge.out` in vydanne.config.mjs.");
117
+ return out;
118
+ }
119
+ if (!cfg) return out;
120
+ out.fromConfig = true;
121
+ if (!config.bridge?.out && cfg.out) out.root = cfg.out;
122
+ const devices = Array.isArray(cfg.devices) ? cfg.devices : Object.values(cfg.devices || {});
123
+ for (const d of devices) {
124
+ for (const s of d?.screenshots || []) {
125
+ if (!s?.target) continue;
126
+ // Icons and feature graphics are written as `<target>.png` at the root, ignoring `dir` — see
127
+ // zdymak/src/screenshots.mjs. Both spellings are recorded so neither can be mistaken for a locale.
128
+ out.knownDirs.add(s.dir || s.target);
129
+ out.knownDirs.add(s.target);
130
+ }
131
+ }
132
+ return out;
133
+ }
134
+
135
+ /** First candidate directory that exists under `from`, or null. */
136
+ const pick = (from, candidates) => candidates.find((c) => fs.existsSync(path.join(from, c))) ?? null;
137
+
138
+ /** Empty a directory, keeping the directory itself. */
139
+ function reset(p) {
140
+ fs.rmSync(p, { recursive: true, force: true });
141
+ fs.mkdirSync(p, { recursive: true });
142
+ }
143
+
144
+ export async function run(config) {
145
+ const dryRun = process.argv.includes("--dry-run");
146
+ const zd = await readZdymak(config);
147
+ const SRC = path.resolve(zd.root);
148
+ if (!fs.existsSync(SRC)) {
149
+ console.error(red(`bridge: no ${path.relative(process.cwd(), SRC)} — run \`zdymak screenshots\` (or \`zdymak build\`) first.`));
150
+ return false;
151
+ }
152
+
153
+ const appleSlots = { ...APPLE_SLOTS, ...(config.bridge?.apple || {}) };
154
+ const macSlots = Object.fromEntries(
155
+ Object.entries({ ...MAC_SLOT, ...(config.bridge?.apple || {}) }).filter(([t]) => t in MAC_DEVICE),
156
+ );
157
+ const iosSlots = Object.fromEntries(Object.entries(appleSlots).filter(([t]) => t in IOS_DEVICE));
158
+ const playSources = { ...PLAY_SOURCES, ...(config.bridge?.play || {}) };
159
+
160
+ const APPLE = path.resolve(screenshotBase("IOS", config));
161
+ const APPLE_MAC = path.resolve(screenshotBase("MAC_OS", config));
162
+
163
+ // ── PLAN FIRST, WRITE SECOND ────────────────────────────────────────────────────────────────────
164
+ // Nothing below touches the disk. The old shape reset the destination at the top and discovered it
165
+ // had nothing to put there at the bottom — so an empty `store-assets` emptied the screenshots folder
166
+ // and *then* reported failure, and a `--dry-run` could not check the files a real run would refuse
167
+ // because they had not been copied yet. Planning first makes the dry run exact and the failure paths
168
+ // non-destructive, at the cost of one array.
169
+ const plan = []; // { from, to, store: 'apple' | 'play' }
170
+ const noListing = [];
171
+ const noLanguage = [];
172
+ const ignored = [];
173
+ const carried = new Set();
174
+ const written = new Set();
175
+
176
+ // ── Apple ───────────────────────────────────────────────────────────────────────────────────────
177
+ // The root of store-assets holds the untranslated (primary) set; a locale-shaped directory beside it
178
+ // is a translated one.
179
+ // Everything the bridge itself knows how to read counts as a known output, whether or not
180
+ // zdymak.config.mjs was readable — otherwise a project with no zdymak config has each of its own
181
+ // source directories reported as "neither an output nor a locale" while being bridged correctly.
182
+ const knownDirs = new Set([
183
+ ...zd.knownDirs,
184
+ ...Object.values(appleSlots).flat(),
185
+ ...Object.values(macSlots).flat(),
186
+ ...Object.values(playSources).flat(),
187
+ ]);
188
+ const localeDirs = dirsIn(SRC).filter((d) => {
189
+ if (knownDirs.has(d)) return false;
190
+ if (LOCALE_SHAPED.test(d)) return true;
191
+ ignored.push(d);
192
+ return false;
193
+ });
194
+
195
+ for (const source of [null, ...localeDirs]) {
196
+ const from = source ? path.join(SRC, source) : SRC;
197
+ const asc = source ? toAsc(source, config.localeMap) : config.primaryLocale;
198
+ // A code with no App Store language (Belarusian) is skipped — a locale with no folder falls back
199
+ // to the primary listing, which is the intent.
200
+ if (!asc) { noLanguage.push(source); continue; }
201
+
202
+ // Only ship screenshots for a locale that also has LISTING TEXT. Uploading them alone makes fill
203
+ // create the App Store localization to attach them to, and a created localization stops falling
204
+ // back to the primary one — the store would show a page with pictures and an empty description
205
+ // instead of the primary text it shows today. Silent, and worse than not translating at all. When
206
+ // the copy for a locale lands, this picks its screenshots up with no edit here.
207
+ if (!fs.existsSync(path.join(config.metadataDir, asc))) { noListing.push(asc); continue; }
208
+
209
+ for (const [root, slots] of [[APPLE, iosSlots], [APPLE_MAC, macSlots]]) {
210
+ for (const [slot, candidates] of Object.entries(slots)) {
211
+ const dir = pick(from, [candidates].flat());
212
+ if (!dir) continue;
213
+ for (const img of imagesIn(path.join(from, dir))) {
214
+ // fill selects the display type off the token before the first underscore, so an unprefixed
215
+ // copy would be a file it silently ignores, in the folder where it looks.
216
+ plan.push({ from: path.join(from, dir, img), to: path.join(root, asc, `${slot}_${img}`), store: "apple" });
217
+ carried.add(dir);
218
+ written.add(asc);
219
+ }
220
+ }
221
+ }
222
+ }
223
+
224
+ // ── Play ────────────────────────────────────────────────────────────────────────────────────────
225
+ // Only the untranslated root is carried: Play graphics default to one set at `defaultLocale`, and an
226
+ // app that opts into per-language art (`google.imageLocales`) points `fill` at the subdirectories it
227
+ // maintains itself.
228
+ const playDest = Object.fromEntries(playImages(config).map(([type, src, kind]) => [type, { src, kind }]));
229
+ const playDirs = new Set();
230
+ const playCarried = new Set();
231
+ for (const [type, candidates] of Object.entries(playSources)) {
232
+ const dest = playDest[type];
233
+ if (!dest) continue; // a type this app's images table doesn't have — nothing reads it, so skip it
234
+ if (dest.kind === "file") {
235
+ const file = [candidates].flat().map((c) => path.join(SRC, c)).find((p) => fs.existsSync(p));
236
+ if (!file) continue;
237
+ plan.push({ from: file, to: path.resolve(dest.src), store: "play" });
238
+ playCarried.add(path.basename(file));
239
+ continue;
240
+ }
241
+ const dir = pick(SRC, [candidates].flat());
242
+ // Owned when zdymak produces it, and ALSO when it merely still exists from a previous run — that
243
+ // second half is the point: a form factor dropped from zdymak.config.mjs must stop being uploaded,
244
+ // and "no new files" is exactly when the old ones would otherwise survive forever. A slot with
245
+ // neither is a slot this app does not use, and creating an empty directory for it would only
246
+ // invent a set for `fill` to report on.
247
+ if (dir || fs.existsSync(dest.src)) playDirs.add(path.resolve(dest.src));
248
+ if (!dir) continue;
249
+ for (const img of imagesIn(path.join(SRC, dir))) {
250
+ plan.push({ from: path.join(SRC, dir, img), to: path.resolve(dest.src, img), store: "play" });
251
+ playCarried.add(dir);
252
+ }
253
+ }
254
+
255
+ const appleCount = plan.filter((p) => p.store === "apple").length;
256
+ const playCount = plan.filter((p) => p.store === "play").length;
257
+
258
+ if (!appleCount && !playCount) {
259
+ console.error(red("bridge: nothing was bridged — check that zdymak actually wrote store-assets."));
260
+ if (ignored.length) console.error(` neither a zdymak output nor a locale: ${ignored.join(", ")}`);
261
+ console.error(" Nothing was removed; every destination is untouched.");
262
+ return false;
263
+ }
264
+
265
+ // ── Apple rejects alpha, so catch it on the SOURCES, before anything is copied ──────────────────
266
+ // Checked here rather than after the copy for two reasons: a dry run can now report the refusal a
267
+ // real run would hit, and a real run no longer leaves the rejected file sitting in the folder `fill`
268
+ // uploads from — where the next run would send it to Apple and collect the rejection anyway.
269
+ const appleFiles = plan.filter((p) => p.store === "apple");
270
+ if (appleFiles.length) {
271
+ const { default: sharp } = await import("sharp");
272
+ const withAlpha = [];
273
+ for (const p of appleFiles) {
274
+ if ((await sharp(p.from).metadata()).hasAlpha) withAlpha.push(path.relative(process.cwd(), p.from));
275
+ }
276
+ if (withAlpha.length) {
277
+ console.error(red("bridge: these carry an alpha channel and Apple will reject them:"));
278
+ for (const p of withAlpha) console.error(` ${p}`);
279
+ console.error(" Flatten at the source (zdymak), or per file: VYDANNE_FLATTEN=<png> vydanne iap");
280
+ console.error(" Nothing was copied; every destination is untouched.");
281
+ return false;
282
+ }
283
+ }
284
+
285
+ // ── Write ───────────────────────────────────────────────────────────────────────────────────────
286
+ // Ownership is per STORE: producing any Apple output means the bridge owns both Apple roots, so the
287
+ // macOS one is emptied even on a run that wrote nothing into it. An app that bridges only Play never
288
+ // has its hand-managed Apple screenshots touched, and vice versa.
289
+ const countFiles = (root) => {
290
+ if (!fs.existsSync(root)) return 0;
291
+ let n = imagesIn(root).length;
292
+ for (const d of dirsIn(root)) n += imagesIn(path.join(root, d)).length;
293
+ return n;
294
+ };
295
+ const owned = [
296
+ ...(appleCount ? [APPLE, APPLE_MAC] : []),
297
+ ...(playCount ? [...playDirs] : []),
298
+ ];
299
+ const removals = owned.map((root) => [root, countFiles(root)]).filter(([, n]) => n);
300
+
301
+ if (!dryRun) {
302
+ for (const root of owned) reset(root);
303
+ for (const p of plan) {
304
+ fs.mkdirSync(path.dirname(p.to), { recursive: true });
305
+ fs.copyFileSync(p.from, p.to);
306
+ }
307
+ }
308
+
309
+ // ── Report ──────────────────────────────────────────────────────────────────────────────────────
310
+ console.log(`▸ Apple → ${path.relative(process.cwd(), APPLE)}`);
311
+ console.log(` ${appleCount} files · ${written.size} locale(s) · ${[...carried].join(", ") || "nothing"}`);
312
+ if (noLanguage.length) console.log(` no App Store language, falls back to ${config.primaryLocale}: ${noLanguage.join(" ")}`);
313
+ if (noListing.length) console.log(` held back (screenshots ready, listing text missing): ${noListing.join(" ")}`);
314
+
315
+ console.log(`▸ Play → ${[...playDirs].map((d) => path.relative(process.cwd(), d)).join(", ") || "nothing"}`);
316
+ console.log(` ${playCount} files · ${[...playCarried].join(", ") || "nothing"}`);
317
+
318
+ // Said every run, because "what the bridge is about to delete" is the one thing a dry run exists to
319
+ // show — and because a destination emptied on purpose (its form factor dropped upstream) looks
320
+ // exactly like one that was never filled.
321
+ for (const [root, n] of removals) {
322
+ console.log(yellow(` ${dryRun ? "would replace" : "replaced"} ${n} existing file(s) in ${path.relative(process.cwd(), root)}`));
323
+ }
324
+ if (ignored.length) console.log(yellow(` ignored (neither a zdymak output nor a locale): ${ignored.join(", ")}`));
325
+
326
+ console.log(green(dryRun ? "✓ dry run — nothing written" : "✓ bridged — `vydanne fill` can now find every set"));
327
+ return true;
328
+ }
@@ -1,13 +1,70 @@
1
1
  import PDFDocument from "pdfkit";
2
2
  import fs from "node:fs";
3
- import { green, yellow } from "../util.mjs";
3
+ import { green, yellow, red } from "../util.mjs";
4
+
5
+ /**
6
+ * The US encryption self-classification report PDF (ECCN 5D002, License Exception ENC 740.17(b)(1)) —
7
+ * the document for the ASC "App Encryption Documentation" US slot. France is a separate ANSSI territory
8
+ * upload; the US needs no CCATS. Native pdfkit, no Python.
9
+ *
10
+ * THIS COMMAND MAKES LEGAL ASSERTIONS, SO IT DOES NOT GUESS. It used to hold a hardcoded cryptography
11
+ * inventory — AES-GCM, HMAC-SHA256, Ed25519, "resolves to Apple CryptoKit" — and a statement claiming
12
+ * the product did end-to-end encryption for cross-device sync and that "a self-classification report
13
+ * has been submitted to BIS and the NSA". Every app the tool was pointed at got the same page. That is
14
+ * wrong twice over: it describes cryptography an app may not contain, and it asserts a filing with two
15
+ * US government agencies that whoever ran the command may never have made. A wrong screenshot costs a
16
+ * review cycle; this is a document someone signs their company's name under.
17
+ *
18
+ * So the app declares its own inventory and its own statement, exactly the way `accessibility` declares
19
+ * its own claims, and for the same reason: silence is not consent. `export.filed` is separate and
20
+ * defaults to false, because "we intend to file" and "we have filed" are different sentences and only
21
+ * the filer knows which is true.
22
+ */
23
+
24
+ const EXAMPLE = ` export: {
25
+ encryption: "standard", // "standard" | "none" | "exempt"
26
+ appName: "Your App",
27
+ version: "1.2",
28
+ teamId: "ABCDE12345",
29
+ // What the app actually contains. Every row is [purpose, algorithm, key size].
30
+ algorithms: [
31
+ ["Transport", "TLS 1.2 / 1.3", "standard"],
32
+ ],
33
+ // One paragraph in your own words, describing what the app does with cryptography.
34
+ statement: "The product uses TLS for network transport only. It is a mass-market consumer " +
35
+ "application distributed through public app stores, uses only standard published algorithms, " +
36
+ "and qualifies for export under License Exception ENC, EAR 740.17(b)(1), ECCN 5D002.",
37
+ // Only true once the report has ACTUALLY been emailed to BIS and the NSA.
38
+ filed: false,
39
+ },`;
40
+
41
+ /** Returns a human-readable problem, or null when the declaration is usable. */
42
+ export function validate(config) {
43
+ const e = config.export || {};
44
+ if ((e.encryption || "standard") !== "standard") return null; // nothing to self-classify
45
+ const missing = [];
46
+ if (!Array.isArray(e.algorithms) || !e.algorithms.length) missing.push("algorithms");
47
+ if (typeof e.statement !== "string" || !e.statement.trim()) missing.push("statement");
48
+ if (!missing.length) return null;
49
+ return [
50
+ `compliance: export.${missing.join(" and export.")} missing.`,
51
+ "This command generates a US export-compliance document that makes factual claims about your",
52
+ "app's cryptography. It will not supply them for you — the previous default described a specific",
53
+ "app's crypto and asserted a BIS/NSA filing, for every app it was run against.",
54
+ "",
55
+ "Declare what your app actually contains:",
56
+ "",
57
+ EXAMPLE,
58
+ ].join("\n");
59
+ }
4
60
 
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
61
  export async function run(config) {
9
62
  const e = config.export || {};
10
- if ((e.encryption || "standard") !== "standard") { console.log(yellow("export.encryption != standard — nothing to self-classify")); return true; }
63
+ if ((e.encryption || "standard") !== "standard") { console.log(yellow(`export.encryption = ${e.encryption} — nothing to self-classify`)); return true; }
64
+
65
+ const problem = validate(config);
66
+ if (problem) { console.error(red(problem)); return false; }
67
+
11
68
  const appName = e.appName || config.bundleId.split(".").pop();
12
69
  const out = `export-compliance/${config.bundleId}-US-encryption-self-classification.pdf`;
13
70
  fs.mkdirSync("export-compliance", { recursive: true });
@@ -27,19 +84,31 @@ export async function run(config) {
27
84
  h("Classification");
28
85
  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
86
  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.");
87
+ p("The application uses only standard, published cryptographic algorithms (NIST / IETF). No proprietary or non-standard cryptography is implemented.");
31
88
  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})`);
89
+ for (const row of e.algorithms) {
90
+ const [purpose, alg, ks] = row;
91
+ doc.font("Helvetica").fontSize(10.5).fillColor("#1e1e1e").text(`• ${purpose} — ${alg}${ks ? ` (${ks})` : ""}`);
34
92
  }
35
93
  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).");
94
+ // The declared statement, and NOTHING appended to it. The filing sentence is added only when the
95
+ // app says the filing happened, because a PDF that claims it has been submitted is the one thing
96
+ // here that cannot be walked back.
97
+ p(e.statement.trim());
98
+ if (e.filed) {
99
+ doc.moveDown(0.4);
100
+ p("A self-classification report has been submitted to BIS (crypt-supp8@bis.doc.gov) and the NSA (enc@nsa.gov).");
101
+ }
37
102
  doc.end();
38
103
  stream.on("finish", resolve);
39
104
  stream.on("error", reject);
40
105
  });
41
106
 
42
107
  console.log(green(`wrote ${out}`));
108
+ if (!e.filed) {
109
+ console.log(yellow(" export.filed is false — the PDF does NOT claim the report was submitted."));
110
+ console.log(" Email it to crypt-supp8@bis.doc.gov and enc@nsa.gov, then set export.filed: true.");
111
+ }
43
112
  if (e.france) console.log(yellow("France: file + upload the ANSSI declaration too (territory rule). US: email BIS+NSA, no CCATS."));
44
113
  return true;
45
114
  }