vydanne 0.5.0 → 0.7.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,151 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { red, yellow, green } from "./util.mjs";
4
+
5
+ /**
6
+ * Refuse to send one store a listing that talks about the other one.
7
+ *
8
+ * WHY THIS IS IN THE TOOL AND NOT IN A CHECKLIST. Both stores forbid it, both enforce it by
9
+ * REJECTING a submission, and it is the single easiest thing to do by accident — because the two
10
+ * listings are written from the same source copy, by the same person, often by a translator who was
11
+ * handed an English master that happened to say "also on Google Play". Apple's App Review guideline
12
+ * 2.3.10 is explicit ("does not include names, icons, or imagery of other mobile platforms"), and
13
+ * Google Play's Store Listing and Promotion policy is the mirror of it. Nobody discovers this while
14
+ * writing the copy; they discover it days later, from a rejection, in one locale out of twenty.
15
+ *
16
+ * So it is checked HERE, against the local files that are about to be uploaded, before anything is
17
+ * sent. That is the only place it can be caught for free.
18
+ *
19
+ * WHAT IT DELIBERATELY DOES NOT DO. It does not flag a store's own platform — "Android" belongs in
20
+ * a Play listing and "iPhone" belongs in an App Store one. And it does not flag the bare word
21
+ * "Play", which is a verb every game listing on earth uses; only the store's actual name.
22
+ *
23
+ * Findings come in two tiers, because the certainty differs:
24
+ * block — unambiguous: a store name, a store URL, a competing platform's device name.
25
+ * warn — a word that is usually a reference but sometimes just a word ("apple" in a game about
26
+ * fruit). Reported, never fatal, and silenceable per app.
27
+ *
28
+ * An app with a genuine exception sets `allowCrossStoreTerms: ["..."]` in its vydanne config.
29
+ */
30
+
31
+ /** Terms that must not appear in a listing for [store]. Ordered longest-first so the report names
32
+ * the most specific match rather than a fragment of it. */
33
+ const FOREIGN = {
34
+ apple: [
35
+ { term: "play.google.com", re: /play\.google\.com/i, level: "block" },
36
+ { term: "Google Play", re: /google\s*play/i, level: "block" },
37
+ { term: "Play Store", re: /\bplay[- ]?store\b/i, level: "block" },
38
+ { term: "Play Market", re: /\bplay[- ]?market\b/i, level: "block" },
39
+ { term: "Android", re: /\bandroid\b/i, level: "block" },
40
+ { term: "安卓", re: /安卓/, level: "block" },
41
+ { term: "アンドロイド", re: /アンドロイド/, level: "block" },
42
+ { term: "안드로이드", re: /안드로이드/, level: "block" },
43
+ { term: "Андроид/Андроїд", re: /андро[иї]д/i, level: "block" },
44
+ { term: "أندرويد", re: /أندرويد/, level: "block" },
45
+ { term: "אנדרואיד", re: /אנדרואיד/, level: "block" },
46
+ { term: "एंड्रॉइड", re: /एंड्रॉ?इड/, level: "block" },
47
+ { term: "APK", re: /\bapk\b/i, level: "warn" },
48
+ { term: "Chromebook", re: /\bchromebook\b/i, level: "warn" },
49
+ ],
50
+ google: [
51
+ { term: "apps.apple.com", re: /apps\.apple\.com/i, level: "block" },
52
+ { term: "App Store", re: /\bapp[- ]?store\b/i, level: "block" },
53
+ { term: "TestFlight", re: /\btestflight\b/i, level: "block" },
54
+ { term: "iPhone", re: /\biphone\b/i, level: "block" },
55
+ { term: "iPad", re: /\bipad(os)?\b/i, level: "block" },
56
+ { term: "iOS", re: /\bios\b/i, level: "block" },
57
+ { term: "Apple Arcade", re: /\bapple\s+arcade\b/i, level: "block" },
58
+ { term: "苹果", re: /苹果/, level: "block" },
59
+ { term: "애플", re: /애플/, level: "block" },
60
+ { term: "アップル", re: /アップル/, level: "block" },
61
+ // Sometimes a fruit, so it is reported rather than fatal.
62
+ { term: "Apple", re: /\bapple\b/i, level: "warn" },
63
+ ],
64
+ };
65
+
66
+ /** Local text a store actually uploads. Keys are the file basenames each fill() reads. */
67
+ const APPLE_FILES = [
68
+ "name", "subtitle", "description", "keywords", "promotional_text",
69
+ "release_notes", "marketing_url", "support_url",
70
+ ];
71
+ const PLAY_FILES = ["title", "short_description", "full_description"];
72
+
73
+ /** One short line of context so a finding can be found and fixed without opening the file blind. */
74
+ function excerpt(text, match) {
75
+ const at = text.toLowerCase().indexOf(match.toLowerCase());
76
+ if (at < 0) return "";
77
+ const from = Math.max(0, at - 28);
78
+ const to = Math.min(text.length, at + match.length + 28);
79
+ return `${from ? "…" : ""}${text.slice(from, to).replace(/\s+/g, " ")}${to < text.length ? "…" : ""}`;
80
+ }
81
+
82
+ /**
83
+ * Scan the local metadata tree for [store].
84
+ *
85
+ * @returns {{findings: Array, scanned: number}} findings carry {level, locale, field, term, excerpt}
86
+ */
87
+ export function scanCrossStore(store, metadataDir, allow = []) {
88
+ const rules = FOREIGN[store].filter((r) => !allow.some((a) => a.toLowerCase() === r.term.toLowerCase()));
89
+ const files = store === "apple" ? APPLE_FILES : PLAY_FILES;
90
+ const findings = [];
91
+ let scanned = 0;
92
+ if (!metadataDir || !fs.existsSync(metadataDir)) return { findings, scanned };
93
+
94
+ for (const entry of fs.readdirSync(metadataDir, { withFileTypes: true })) {
95
+ if (!entry.isDirectory()) continue;
96
+ for (const field of files) {
97
+ const p = path.join(metadataDir, entry.name, `${field}.txt`);
98
+ if (!fs.existsSync(p)) continue;
99
+ const text = fs.readFileSync(p, "utf8");
100
+ scanned++;
101
+ for (const rule of rules) {
102
+ const m = text.match(rule.re);
103
+ if (!m) continue;
104
+ findings.push({
105
+ level: rule.level,
106
+ locale: entry.name,
107
+ field,
108
+ term: rule.term,
109
+ excerpt: excerpt(text, m[0]),
110
+ });
111
+ }
112
+ }
113
+ }
114
+ return { findings, scanned };
115
+ }
116
+
117
+ /** The other store's human name, for the message. */
118
+ const OTHER = { apple: "Google Play / Android", google: "the App Store / iOS" };
119
+ const GUIDELINE = {
120
+ apple: "App Review guideline 2.3.10 — metadata must not name other mobile platforms.",
121
+ google: "Google Play Store Listing and Promotion policy — no references to other app stores.",
122
+ };
123
+
124
+ /**
125
+ * Report findings. Returns false when something BLOCKING was found, so callers can refuse to upload.
126
+ *
127
+ * Printing every locale rather than the first is the point: this fails one translation at a time,
128
+ * and knowing it is 3 locales and not 20 is the difference between a fix and a re-translation.
129
+ */
130
+ export function reportCrossStore(store, metadataDir, allow = []) {
131
+ const { findings, scanned } = scanCrossStore(store, metadataDir, allow);
132
+ if (!scanned) return true;
133
+
134
+ const blocking = findings.filter((f) => f.level === "block");
135
+ const warnings = findings.filter((f) => f.level === "warn");
136
+
137
+ if (blocking.length) {
138
+ console.log(red(` cross-store: ${blocking.length} reference(s) to ${OTHER[store]} in listing text`));
139
+ console.log(` ${GUIDELINE[store]}`);
140
+ for (const f of blocking) {
141
+ console.log(` ${red("x")} ${f.locale}/${f.field}: "${f.term}" ${f.excerpt}`);
142
+ }
143
+ }
144
+ for (const f of warnings) {
145
+ console.log(` ${yellow("!")} ${f.locale}/${f.field}: "${f.term}" — check this is not a platform reference ${f.excerpt}`);
146
+ }
147
+ if (!blocking.length && !warnings.length) {
148
+ console.log(green(` cross-store: clean (${scanned} files)`));
149
+ }
150
+ return blocking.length === 0;
151
+ }
package/src/index.mjs CHANGED
@@ -1,6 +1,63 @@
1
- // Programmatic entry (the CLI is bin/vydanne.mjs). `import { Client, loadConfig } from "vydanne"`.
1
+ // Programmatic entry (the CLI is bin/vydanne.mjs). `import { runCommand, loadConfig } from "vydanne"`.
2
+ //
3
+ // This used to export a client, a config loader and the command REGISTRY — a table of `{ mod: "fill" }`
4
+ // filenames that nothing outside the package could resolve, because `exports` maps only ".". So a
5
+ // consumer could see that `fill` existed and had no way to run it. `runCommand` is that missing half:
6
+ // the same dispatch bin/ performs, minus the argv parsing and the process.exit.
7
+ import { Client } from "./client.mjs";
8
+ import { loadConfig } from "./config.mjs";
9
+ import { COMMANDS, PLAY_COMMANDS } from "./registry.mjs";
10
+
2
11
  export { Client } from "./client.mjs";
12
+ export { PlayClient } from "./play/client.mjs";
3
13
  export { loadConfig, CONFIG_KEYS } from "./config.mjs";
4
- export { COMMANDS, COMMAND_NAMES } from "./registry.mjs";
5
- export { resolveLocales, toAsc, VALID } from "./locales.mjs";
14
+ export { COMMANDS, PLAY_COMMANDS, COMMAND_NAMES } from "./registry.mjs";
15
+ export { resolveLocales, toAsc, VALID, UI_TO_ASC } from "./locales.mjs";
6
16
  export { makeToken } from "./jwt.mjs";
17
+ export { DEFAULT_SCREENSHOT_BASE, IOS_DEVICE, MAC_DEVICE, screenshotBase } from "./screenshots.mjs";
18
+ export { DEFAULT_PLAY_IMAGES, PLAY_IMAGE_KIND, playImages } from "./play/images.mjs";
19
+
20
+ /**
21
+ * Run one command, the way the CLI runs it.
22
+ *
23
+ * `apply` is the same safety gate the `--apply` flag drives, and it defaults to FALSE here for the same
24
+ * reason it does there: a caller that forgets it gets a dry run, never a write. A command that writes
25
+ * to the store receives a client already refusing mutations, so "dry run" is enforced in the client
26
+ * rather than trusted to each command.
27
+ *
28
+ * Returns `{ ok, planned }` — `planned` being the writes a dry run withheld, which is the machine-
29
+ * readable form of what the CLI prints as "N store write(s) withheld".
30
+ *
31
+ * @param {string} name a key of COMMANDS (or of PLAY_COMMANDS when store is "google")
32
+ * @param {object} [opts]
33
+ * @param {object} [opts.config] a loaded config; loaded from disk when omitted
34
+ * @param {string} [opts.configPath] path to vydanne.config.mjs, when loading from disk
35
+ * @param {"apple"|"google"} [opts.store]
36
+ * @param {boolean} [opts.apply] perform store writes (default false — dry run)
37
+ */
38
+ export async function runCommand(name, opts = {}) {
39
+ const { store = "apple", apply = false, configPath } = opts;
40
+ if (store !== "apple" && store !== "google") throw new Error(`vydanne: unknown store '${store}' (expected: apple, google)`);
41
+
42
+ const config = opts.config || (await loadConfig(configPath));
43
+ const table = store === "google" ? PLAY_COMMANDS : COMMANDS;
44
+ const spec = table[name];
45
+ if (!spec) throw new Error(`vydanne: unknown command '${name}' for store '${store}' (try: ${Object.keys(table).join(", ")})`);
46
+
47
+ const dryRun = Boolean(spec.writes) && !apply;
48
+
49
+ if (store === "google") {
50
+ if (!config.google) throw new Error("vydanne: no `google` block in config — add packageName + a service-account key");
51
+ if (!config.google.serviceAccountKey) throw new Error("vydanne: set PLAY_JSON_KEY_FILE (or google.serviceAccountKey) to the Play service-account JSON");
52
+ const { PlayClient } = await import("./play/client.mjs");
53
+ const client = await PlayClient.create({ keyPath: config.google.serviceAccountKey, packageName: config.google.packageName, dryRun });
54
+ const { run } = await import(`./play/commands/${spec.mod}.mjs`);
55
+ return { ok: (await run(config, client)) !== false, planned: [] };
56
+ }
57
+
58
+ const client = spec.client ? new Client({ keyId: config.keyId, issuerId: config.issuerId, dryRun }) : null;
59
+ const { run } = await import(`./commands/${spec.mod}.mjs`);
60
+ // altool authenticates on its own rather than through our JWT, so it needs the raw ids.
61
+ const ok = await run(config, client, spec.credentials ? { keyId: config.keyId, issuerId: config.issuerId } : undefined);
62
+ return { ok: ok !== false, planned: client?.planned ?? [] };
63
+ }
package/src/locales.mjs CHANGED
@@ -8,19 +8,41 @@ export const VALID = new Set([
8
8
  "te-IN", "th", "tr", "uk", "ur-PK", "vi", "zh-Hans", "zh-Hant",
9
9
  ]);
10
10
 
11
+ // Short UI code -> App Store code. `nb` and `iw` are Android's spellings of Norwegian and Hebrew: a
12
+ // project that names its resource folders the Android way (values-nb, values-iw) would otherwise have
13
+ // both locales resolve to "no App Store language" and fall back to the primary listing — a silently
14
+ // missing translation for a language Apple does support under a different code.
11
15
  export const UI_TO_ASC = {
12
16
  ar: "ar-SA", bn: "bn-BD", de: "de-DE", es: "es-ES", fr: "fr-FR", nl: "nl-NL",
13
17
  pt: "pt-BR", ur: "ur-PK", zh: "zh-Hans", en: "en-US",
18
+ nb: "no", iw: "he", in: "id", ji: "he",
14
19
  };
15
20
 
16
- export const toAsc = (code) => (VALID.has(code) ? code : UI_TO_ASC[code] || null);
21
+ /**
22
+ * `code` as an App Store locale, or null when Apple has no listing language for it.
23
+ *
24
+ * `extra` is the app's own `localeMap` — the extension point this table lacked. Apple's list moves, and
25
+ * projects spell locales in whatever their UI framework uses; neither is a reason to need a vydanne
26
+ * release. An app can map its code onto a VALID one, and only onto a VALID one: an override pointing at
27
+ * a language Apple does not have would fail at upload instead of here.
28
+ */
29
+ export const toAsc = (code, extra) => {
30
+ if (VALID.has(code)) return code;
31
+ const mapped = extra?.[code] ?? UI_TO_ASC[code] ?? null;
32
+ return mapped && VALID.has(mapped) ? mapped : null;
33
+ };
17
34
 
18
- export function resolveLocales(uiCodes = []) {
35
+ export function resolveLocales(uiCodes = [], extra) {
19
36
  const supported = {};
20
37
  const unsupported = [];
38
+ const invalid = [];
21
39
  for (const c of uiCodes) {
22
- const asc = toAsc(c);
23
- asc ? (supported[c] = asc) : unsupported.push(c);
40
+ const asc = toAsc(c, extra);
41
+ if (asc) supported[c] = asc;
42
+ // An override that names a code Apple doesn't have is a config mistake, not a missing language —
43
+ // kept apart so the report can say which it is instead of blaming the locale.
44
+ else if (extra?.[c]) invalid.push(`${c} -> ${extra[c]}`);
45
+ else unsupported.push(c);
24
46
  }
25
- return { supported, unsupported };
47
+ return { supported, unsupported, invalid };
26
48
  }
@@ -0,0 +1,133 @@
1
+ import fs from "node:fs";
2
+ import zlib from "node:zlib";
3
+
4
+ /**
5
+ * Read the versionCode out of an `.aab`, locally, before anything is uploaded.
6
+ *
7
+ * WHY. Release notes resolve to `<metadataDir>/<locale>/changelogs/<versionCode>.txt`, but in repos
8
+ * that derive versionCode from `git rev-list --count HEAD` the number is unknowable until the build is
9
+ * cut — every commit moves it. So nobody can name the changelog file in advance, the fallback quietly
10
+ * wins, and the first time the real number appears in a log is AFTER a multi-megabyte upload. The
11
+ * bundle itself has known its own versionCode all along: it is an attribute on the `<manifest>`
12
+ * element of `base/manifest/AndroidManifest.xml`.
13
+ *
14
+ * HOW, without Java. bundletool is the official reader and is a JVM tool; shelling out to it would be
15
+ * the first Java dependency in a package whose whole pitch is native Node. But the format is shallow:
16
+ * an .aab is a ZIP, and the manifest inside is aapt2's protobuf XML. Neither needs a library —
17
+ * `zlib.inflateRawSync` decompresses the entry, and protobuf's wire format is walkable generically:
18
+ * find the submessage that looks like an XmlAttribute named "versionCode" (field 2, its `name`) and
19
+ * take its value (field 3 as a decimal string, or the first varint inside field 6, the compiled item).
20
+ * Matching by attribute NAME rather than by the exact Resources.proto field numbers for Primitive is
21
+ * deliberate — those internals have shifted between aapt2 versions; "an attribute called versionCode
22
+ * on the manifest of an Android app" has not.
23
+ *
24
+ * Returns null on anything unexpected rather than throwing: the caller has a correct-by-construction
25
+ * fallback (Play reports the versionCode after upload), so a parse failure must degrade to the old
26
+ * behaviour, never block a release.
27
+ */
28
+ export function readAabVersionCode(file) {
29
+ try {
30
+ const manifest = zipEntry(fs.readFileSync(file), "base/manifest/AndroidManifest.xml");
31
+ return manifest ? findVersionCode(manifest) : null;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ /** Extract one entry from a ZIP buffer (stored or deflated), or null. */
38
+ function zipEntry(buf, wanted) {
39
+ // End-of-central-directory: scan back from the end (the record allows a trailing comment).
40
+ let eocd = -1;
41
+ for (let i = buf.length - 22; i >= Math.max(0, buf.length - 22 - 65535); i--) {
42
+ if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
43
+ }
44
+ if (eocd < 0) return null;
45
+ let off = buf.readUInt32LE(eocd + 16); // central directory offset
46
+ const count = buf.readUInt16LE(eocd + 10);
47
+ for (let n = 0; n < count && off + 46 <= buf.length; n++) {
48
+ if (buf.readUInt32LE(off) !== 0x02014b50) return null;
49
+ const method = buf.readUInt16LE(off + 10);
50
+ const csize = buf.readUInt32LE(off + 20);
51
+ const nameLen = buf.readUInt16LE(off + 28);
52
+ const extraLen = buf.readUInt16LE(off + 30);
53
+ const commentLen = buf.readUInt16LE(off + 32);
54
+ const localOff = buf.readUInt32LE(off + 42);
55
+ const name = buf.toString("utf8", off + 46, off + 46 + nameLen);
56
+ if (name === wanted) {
57
+ // The local header repeats name/extra with its OWN lengths (extra often differs) — read them.
58
+ if (buf.readUInt32LE(localOff) !== 0x04034b50) return null;
59
+ const lname = buf.readUInt16LE(localOff + 26);
60
+ const lextra = buf.readUInt16LE(localOff + 28);
61
+ const data = buf.subarray(localOff + 30 + lname + lextra, localOff + 30 + lname + lextra + csize);
62
+ if (method === 0) return data;
63
+ if (method === 8) return zlib.inflateRawSync(data);
64
+ return null;
65
+ }
66
+ off += 46 + nameLen + extraLen + commentLen;
67
+ }
68
+ return null;
69
+ }
70
+
71
+ /** Parse one protobuf message into its fields. Throws on anything that isn't valid wire format. */
72
+ function protoFields(buf) {
73
+ const out = [];
74
+ let i = 0;
75
+ const varint = () => {
76
+ let v = 0, shift = 0;
77
+ for (;;) {
78
+ if (i >= buf.length || shift > 49) throw new Error("varint"); // >2^49 can't be a versionCode anyway
79
+ const b = buf[i++];
80
+ v += (b & 0x7f) * 2 ** shift;
81
+ if (!(b & 0x80)) return v;
82
+ shift += 7;
83
+ }
84
+ };
85
+ while (i < buf.length) {
86
+ const key = varint();
87
+ const no = Math.floor(key / 8), wire = key % 8;
88
+ if (wire === 0) out.push({ no, wire, val: varint() });
89
+ else if (wire === 2) { const len = varint(); if (i + len > buf.length) throw new Error("len"); out.push({ no, wire, bytes: buf.subarray(i, i + len) }); i += len; }
90
+ else if (wire === 5) { i += 4; out.push({ no, wire }); }
91
+ else if (wire === 1) { i += 8; out.push({ no, wire }); }
92
+ else throw new Error("wire");
93
+ }
94
+ return out;
95
+ }
96
+
97
+ /** Depth-first hunt for an XmlAttribute whose name (field 2) is "versionCode". */
98
+ function findVersionCode(buf) {
99
+ let fields;
100
+ try { fields = protoFields(buf); } catch { return null; } // not a message — a string that happened to be field-2
101
+ const name = fields.find((f) => f.no === 2 && f.wire === 2);
102
+ if (name && name.bytes.toString("utf8") === "versionCode") {
103
+ const value = fields.find((f) => f.no === 3 && f.wire === 2)?.bytes.toString("utf8");
104
+ if (value && /^\d+$/.test(value)) return Number(value);
105
+ const compiled = fields.find((f) => f.no === 6 && f.wire === 2);
106
+ if (compiled) {
107
+ const v = firstVarint(compiled.bytes);
108
+ if (v != null) return v;
109
+ }
110
+ return null;
111
+ }
112
+ for (const f of fields) {
113
+ if (f.wire !== 2) continue;
114
+ const found = findVersionCode(f.bytes);
115
+ if (found != null) return found;
116
+ }
117
+ return null;
118
+ }
119
+
120
+ /** The first varint anywhere in a message — inside Item→Primitive that is the integer value itself
121
+ * (the attribute-level varints, like resource_id, live OUTSIDE the compiled item). */
122
+ function firstVarint(buf) {
123
+ let fields;
124
+ try { fields = protoFields(buf); } catch { return null; }
125
+ for (const f of fields) {
126
+ if (f.wire === 0) return f.val;
127
+ if (f.wire === 2) {
128
+ const v = firstVarint(f.bytes);
129
+ if (v != null) return v;
130
+ }
131
+ }
132
+ return null;
133
+ }
@@ -9,13 +9,16 @@ const UPLOAD = "https://androidpublisher.googleapis.com/upload/androidpublisher/
9
9
  // transaction — insert an edit, mutate listings/images/details against it, then commit (all-or-nothing).
10
10
  // Nothing is live until commit; a dropped edit changes nothing. Image bytes go to the /upload endpoint.
11
11
  export class PlayClient {
12
- static async create({ keyPath, packageName }) {
12
+ static async create({ keyPath, packageName, dryRun = false }) {
13
13
  const token = await getAccessToken(keyPath);
14
- return new PlayClient(token, packageName);
14
+ return new PlayClient(token, packageName, dryRun);
15
15
  }
16
- constructor(token, packageName) {
16
+ constructor(token, packageName, dryRun = false) {
17
17
  this.token = token;
18
18
  this.pkg = packageName;
19
+ // Gates the COMMIT, not the requests: the edit is still built and validated against Google for real,
20
+ // which is the whole advantage of Play's transaction over Apple's fire-and-forget PATCHes.
21
+ this.dryRun = dryRun;
19
22
  }
20
23
 
21
24
  async req(method, subpath, { body, base = BASE } = {}) {
@@ -1,10 +1,13 @@
1
+ import crypto from "node:crypto";
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  import { green, red, yellow } from "../../util.mjs";
5
+ import { playImages, imageLocales } from "../images.mjs";
4
6
 
5
7
  // [Play listing attribute, local metadata filename] — supply's convention under fastlane/metadata/android.
6
8
  const FIELDS = [["title", "title"], ["shortDescription", "short_description"], ["fullDescription", "full_description"]];
7
9
  const norm = (s) => (s == null ? null : String(s).replace(/\r/g, "").replace(/\n+$/, "").trim());
10
+ const sha1 = (buf) => crypto.createHash("sha1").update(buf).digest("hex");
8
11
 
9
12
  // Show what differs between local Play sources (fastlane/metadata/android/<locale>/*.txt) and the live Play
10
13
  // listing — a dry-run of `fill --store google`.
@@ -38,6 +41,43 @@ export async function run(config, client) {
38
41
  const localSet = new Set(localLangs);
39
42
  const extra = listings.map((l) => l.language).filter((x) => !localSet.has(x));
40
43
  if (extra.length) console.log(` ${yellow("Play-only languages")} (no local folder): ${extra.join(", ")}`);
44
+
45
+ // Images, by CONTENT. This command compared nothing here at all, so "in sync" was a claim about the
46
+ // text only — a full recapture of every screenshot reported nothing to do, which is the same bug the
47
+ // Apple diff had with counts, one step worse. Play's images.list returns the sha1 of what it holds
48
+ // (the comparison supply itself uses to skip identical uploads), so local bytes can be checked
49
+ // against the store without downloading anything. Only types with a LOCAL asset are judged, and a
50
+ // remote-only type is left unflagged — mirroring fill, which never deletes by omission.
51
+ // Every locale `fill` would upload to, so the two commands agree on what "in sync" covers. With the
52
+ // default (no `google.imageLocales`) that is the one `defaultLocale` this always compared.
53
+ for (const lang of imageLocales(g, localLangs)) {
54
+ for (const [type, src, kind] of playImages(config)) {
55
+ const localized = path.join(src, lang);
56
+ const from = kind === "dir" && fs.existsSync(localized) ? localized : src;
57
+ if (!fs.existsSync(from)) continue;
58
+ const files = kind === "dir" ? fs.readdirSync(from).filter((f) => /\.(png|jpe?g)$/i.test(f)).sort().map((f) => path.join(from, f)) : [from];
59
+ const label = ` ${yellow("images")} ${type}`;
60
+ // A dir that exists but is empty means the local set was deliberately cleared — `fill` will not
61
+ // touch the live one (never-delete-by-omission), so if the store still holds images they are
62
+ // stale and only Play Console can remove them. Reported, not counted as actionable, because no
63
+ // vydanne command would change it.
64
+ if (!files.length) {
65
+ const held = ((await client.listImages(editId, lang, type)).json.images || []).length;
66
+ if (held) console.log(`${label}: local dir empty, store holds ${held} — stale; only Play Console can remove them (@${lang})`);
67
+ continue;
68
+ }
69
+ const local = files.map((f) => sha1(fs.readFileSync(f)));
70
+ const remote = ((await client.listImages(editId, lang, type)).json.images || []).map((i) => i.sha1);
71
+ if (local.length !== remote.length) {
72
+ actionable++;
73
+ console.log(`${label}: local ${local.length} / remote ${remote.length} (@${lang})`);
74
+ } else if (JSON.stringify([...local].sort()) !== JSON.stringify([...remote].sort())) {
75
+ const changed = local.filter((s) => !remote.includes(s)).length;
76
+ actionable++;
77
+ console.log(`${label}: ${changed} of ${local.length} differ in content (@${lang})`);
78
+ }
79
+ }
80
+ }
41
81
  } finally {
42
82
  await client.deleteEdit(editId);
43
83
  }
@@ -1,36 +1,35 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { green, yellow, red } from "../../util.mjs";
4
+ import { reportCrossStore } from "../../crossStore.mjs";
5
+ import { playImages, imageLocales } from "../images.mjs";
4
6
 
5
7
  const FIELDS = [["title", "title"], ["shortDescription", "short_description"], ["fullDescription", "full_description"]];
6
- // Play image type -> local source (a dir of PNGs = screenshots; a single file = graphic). From zdymak.
7
- // Each type is uploaded only when its local asset EXISTS, so an app that lacks (say) tablet shots or a
8
- // znachok icon simply skips that type — a missing local set never deletes the live one.
9
- const IMAGES = [
10
- ["icon", "brand/icons/play/icon-512.png", "file"],
11
- ["featureGraphic", "marketing/out/play-feature-graphic.png", "file"],
12
- ["phoneScreenshots", "marketing/out/play-phone-plain", "dir"],
13
- ["sevenInchScreenshots", "marketing/out/play-tablet7-plain", "dir"],
14
- ["tenInchScreenshots", "marketing/out/play-tablet-plain", "dir"],
15
- ];
16
8
 
17
9
  // Push the Play listing (text + images) inside one Edit, then validate and commit. iOS/Android are separate
18
10
  // stores — this is the Google half. Images only touch a type whose local asset EXISTS (so a missing local
19
- // set never deletes the live one). VYDANNE_DRY=1 validates and discards without committing.
11
+ // set never deletes the live one). Without `--apply` it validates and discards without committing.
20
12
  export async function run(config, client) {
21
13
  const g = config.google;
22
- // SAFE BY DEFAULT: validate + discard the edit unless VYDANNE_COMMIT=1. A store-mutating commit must be
23
- // an explicit opt-in — never the default (a stale/partial local set could otherwise clobber a live one).
24
- const commit = process.env.VYDANNE_COMMIT === "1";
14
+ // SAFE BY DEFAULT: validate + discard the edit unless `--apply`. A store-mutating commit must be an
15
+ // explicit opt-in — never the default (a stale/partial local set could otherwise clobber a live one).
16
+ const commit = !client.dryRun;
17
+ const IMAGES = playImages(config);
25
18
  const localLangs = fs.existsSync(g.metadataDir)
26
19
  ? fs.readdirSync(g.metadataDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name)
27
20
  : [];
28
21
  const haveImages = IMAGES.some(([, src]) => fs.existsSync(src));
29
22
  if (!localLangs.length && !haveImages) {
30
- console.log(yellow(`fill(play): no local listing folders under ${g.metadataDir} and no zdymak play assets — nothing to upload yet (populate them for Android Phase 2).`));
23
+ console.log(yellow(`fill(play): no local listing folders under ${g.metadataDir} and no local play assets — nothing to upload yet (populate them, or run \`vydanne bridge\`).`));
31
24
  return true;
32
25
  }
33
26
 
27
+ if (process.env.VYDANNE_ALLOW_CROSS_STORE !== "1"
28
+ && !reportCrossStore("google", g.metadataDir, config.allowCrossStoreTerms)) {
29
+ console.error(red("fill(play): refusing to upload — fix the listing text, or set VYDANNE_ALLOW_CROSS_STORE=1."));
30
+ return false;
31
+ }
32
+
34
33
  const editId = await client.newEdit();
35
34
  try {
36
35
  // Listing text
@@ -45,19 +44,28 @@ export async function run(config, client) {
45
44
  }
46
45
  }
47
46
  // Images — replace a type only when the local asset exists (delete-all then upload).
48
- const lang = g.defaultLocale;
49
- for (const [type, src, kind] of IMAGES) {
50
- if (!fs.existsSync(src)) continue;
51
- const files = kind === "dir" ? fs.readdirSync(src).filter((f) => /\.(png|jpe?g)$/i.test(f)).sort().map((f) => path.join(src, f)) : [src];
52
- if (!files.length) continue;
53
- await client.deleteAllImages(editId, lang, type);
54
- for (const f of files) await client.uploadImage(editId, lang, type, f);
55
- console.log(green(` ${lang}/${type}: ${files.length} image(s)`));
47
+ for (const lang of imageLocales(g, localLangs)) {
48
+ for (const [type, src, kind] of IMAGES) {
49
+ // A per-locale override directory (`<src>/<lang>`) wins when it exists, so an app can localize
50
+ // some slots and leave the rest shared without listing every combination in the config.
51
+ const localized = path.join(src, lang);
52
+ const from = kind === "dir" && fs.existsSync(localized) ? localized : src;
53
+ if (!fs.existsSync(from)) continue;
54
+ const files = kind === "dir" ? fs.readdirSync(from).filter((f) => /\.(png|jpe?g)$/i.test(f)).sort().map((f) => path.join(from, f)) : [from];
55
+ // A dir that EXISTS but is empty is different from a missing one: someone (the store-assets
56
+ // bridge, when zdymak stops producing a form factor) deliberately emptied it, expecting the live
57
+ // set to follow. It doesn't — never-delete-by-omission holds — but that must be said, because the
58
+ // silent skip is how a listing keeps showing screenshots of a UI the app no longer has.
59
+ if (!files.length) { console.log(yellow(` ${lang}/${type}: local dir is empty — live set left untouched (delete it in Play Console if it is stale)`)); continue; }
60
+ await client.deleteAllImages(editId, lang, type);
61
+ for (const f of files) await client.uploadImage(editId, lang, type, f);
62
+ console.log(green(` ${lang}/${type}: ${files.length} image(s)`));
63
+ }
56
64
  }
57
65
 
58
66
  const v = await client.validate(editId);
59
67
  if (v.status >= 300) throw new Error(`validate ${v.status}: ${JSON.stringify(v.json).slice(0, 200)}`);
60
- if (!commit) { await client.deleteEdit(editId); console.log(yellow("fill(play): validated — DRY (nothing changed). Review the above, then set VYDANNE_COMMIT=1 to commit.")); return true; }
68
+ if (!commit) { await client.deleteEdit(editId); console.log(yellow("fill(play): validated — DRY (nothing changed). Review the above, then re-run with --apply to commit.")); return true; }
61
69
  const co = await client.commit(editId);
62
70
  if (co.status >= 300) throw new Error(`commit ${co.status}: ${JSON.stringify(co.json).slice(0, 200)}`);
63
71
  console.log(green("fill(play): committed."));
@@ -1,4 +1,5 @@
1
1
  import { green, red, yellow } from "../../util.mjs";
2
+ import { reportCrossStore } from "../../crossStore.mjs";
2
3
 
3
4
  // Play listing limits.
4
5
  const LIMITS = { title: 30, shortDescription: 80, fullDescription: 4000 };
@@ -30,6 +31,10 @@ export async function run(config, client) {
30
31
  } finally {
31
32
  await client.deleteEdit(editId);
32
33
  }
34
+ if (!reportCrossStore("google", g.metadataDir, config.allowCrossStoreTerms)) {
35
+ problems.push("listing text references another app store (see above)");
36
+ }
37
+
33
38
  console.log();
34
39
  if (!problems.length) console.log(green("preflight: no blockers"));
35
40
  else { console.log(red(`preflight: ${problems.length} blocker(s)`)); problems.forEach((p) => console.log(` ${red("x")} ${p}`)); }