vydanne 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }
@@ -9,13 +9,16 @@ const UPLOAD = "https://androidpublisher.googleapis.com/upload/androidpublisher/
9
9
  // transaction — insert an edit, mutate listings/images/details against it, then commit (all-or-nothing).
10
10
  // Nothing is live until commit; a dropped edit changes nothing. Image bytes go to the /upload endpoint.
11
11
  export class PlayClient {
12
- static async create({ keyPath, packageName }) {
12
+ static async create({ keyPath, packageName, dryRun = false }) {
13
13
  const token = await getAccessToken(keyPath);
14
- return new PlayClient(token, packageName);
14
+ return new PlayClient(token, packageName, dryRun);
15
15
  }
16
- constructor(token, packageName) {
16
+ constructor(token, packageName, dryRun = false) {
17
17
  this.token = token;
18
18
  this.pkg = packageName;
19
+ // Gates the COMMIT, not the requests: the edit is still built and validated against Google for real,
20
+ // which is the whole advantage of Play's transaction over Apple's fire-and-forget PATCHes.
21
+ this.dryRun = dryRun;
19
22
  }
20
23
 
21
24
  async req(method, subpath, { body, base = BASE } = {}) {
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { green, yellow, red } from "../../util.mjs";
4
+ import { reportCrossStore } from "../../crossStore.mjs";
4
5
 
5
6
  const FIELDS = [["title", "title"], ["shortDescription", "short_description"], ["fullDescription", "full_description"]];
6
7
  // Play image type -> local source (a dir of PNGs = screenshots; a single file = graphic). From zdymak.
@@ -16,12 +17,12 @@ const IMAGES = [
16
17
 
17
18
  // Push the Play listing (text + images) inside one Edit, then validate and commit. iOS/Android are separate
18
19
  // stores — this is the Google half. Images only touch a type whose local asset EXISTS (so a missing local
19
- // set never deletes the live one). VYDANNE_DRY=1 validates and discards without committing.
20
+ // set never deletes the live one). Without `--apply` it validates and discards without committing.
20
21
  export async function run(config, client) {
21
22
  const g = config.google;
22
- // SAFE BY DEFAULT: validate + discard the edit unless 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";
23
+ // SAFE BY DEFAULT: validate + discard the edit unless `--apply`. A store-mutating commit must be an
24
+ // explicit opt-in — never the default (a stale/partial local set could otherwise clobber a live one).
25
+ const commit = !client.dryRun;
25
26
  const localLangs = fs.existsSync(g.metadataDir)
26
27
  ? fs.readdirSync(g.metadataDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name)
27
28
  : [];
@@ -31,6 +32,12 @@ export async function run(config, client) {
31
32
  return true;
32
33
  }
33
34
 
35
+ if (process.env.VYDANNE_ALLOW_CROSS_STORE !== "1"
36
+ && !reportCrossStore("google", g.metadataDir, config.allowCrossStoreTerms)) {
37
+ console.error(red("fill(play): refusing to upload — fix the listing text, or set VYDANNE_ALLOW_CROSS_STORE=1."));
38
+ return false;
39
+ }
40
+
34
41
  const editId = await client.newEdit();
35
42
  try {
36
43
  // Listing text
@@ -57,7 +64,7 @@ export async function run(config, client) {
57
64
 
58
65
  const v = await client.validate(editId);
59
66
  if (v.status >= 300) throw new Error(`validate ${v.status}: ${JSON.stringify(v.json).slice(0, 200)}`);
60
- if (!commit) { await client.deleteEdit(editId); console.log(yellow("fill(play): validated — DRY (nothing changed). Review the above, then set VYDANNE_COMMIT=1 to commit.")); return true; }
67
+ if (!commit) { await client.deleteEdit(editId); console.log(yellow("fill(play): validated — DRY (nothing changed). Review the above, then re-run with --apply to commit.")); return true; }
61
68
  const co = await client.commit(editId);
62
69
  if (co.status >= 300) throw new Error(`commit ${co.status}: ${JSON.stringify(co.json).slice(0, 200)}`);
63
70
  console.log(green("fill(play): committed."));
@@ -1,4 +1,5 @@
1
1
  import { green, red, yellow } from "../../util.mjs";
2
+ import { reportCrossStore } from "../../crossStore.mjs";
2
3
 
3
4
  // Play listing limits.
4
5
  const LIMITS = { title: 30, shortDescription: 80, fullDescription: 4000 };
@@ -30,6 +31,10 @@ export async function run(config, client) {
30
31
  } finally {
31
32
  await client.deleteEdit(editId);
32
33
  }
34
+ if (!reportCrossStore("google", g.metadataDir, config.allowCrossStoreTerms)) {
35
+ problems.push("listing text references another app store (see above)");
36
+ }
37
+
33
38
  console.log();
34
39
  if (!problems.length) console.log(green("preflight: no blockers"));
35
40
  else { console.log(red(`preflight: ${problems.length} blocker(s)`)); problems.forEach((p) => console.log(` ${red("x")} ${p}`)); }
@@ -79,9 +79,9 @@ export async function run(config, client) {
79
79
  const put = await client.putTrack(editId, track, [release]);
80
80
  if (put.status >= 300) throw new Error(`tracks.update ${put.status}: ${JSON.stringify(put.json).slice(0, 300)}`);
81
81
 
82
- if (process.env.VYDANNE_COMMIT !== "1") {
82
+ if (client.dryRun) {
83
83
  await client.deleteEdit(editId);
84
- console.log(yellow(`\n DRY RUN — edit discarded, nothing changed. Re-run with VYDANNE_COMMIT=1 to publish to "${track}".`));
84
+ console.log(yellow(`\n DRY RUN — edit discarded, nothing changed. Re-run with --apply to publish to "${track}".`));
85
85
  return true;
86
86
  }
87
87
  const res = await client.commit(editId);
package/src/registry.mjs CHANGED
@@ -1,19 +1,29 @@
1
1
  // The canonical command registry — the single source of vydanne's public commands. bin/ dispatches from
2
2
  // this, and the drift guards (scripts/check-docs.mjs, scripts/check-types.mjs) assert every command is
3
3
  // documented in README/SKILL and typed in types/index.d.ts. Add a command here → the guards force it into
4
- // the docs + types before publish. name -> { mod: <file in src/commands>, client: needs an ASC client }
4
+ // the docs + types before publish.
5
+ //
6
+ // name -> { mod: <file in src/commands>, client: needs an ASC client, writes: mutates the STORE }
7
+ //
8
+ // `writes` is what makes a command dry-run unless `--apply` is passed, so it is a safety declaration, not
9
+ // a label: mark a new command `writes: true` the moment it can change anything on the store side. It means
10
+ // the STORE specifically — `privacy` and `compliance` write local files (a record, a PDF) and are not
11
+ // marked, because a dry run that refused to produce a local artefact would just be broken.
5
12
  export const COMMANDS = {
6
- fill: { mod: "fill", client: true },
7
- "age-rating": { mod: "ageRating", client: true },
8
- "review-contact": { mod: "reviewContact", client: true },
9
- accessibility: { mod: "accessibility", client: true },
13
+ fill: { mod: "fill", client: true, writes: true },
14
+ "age-rating": { mod: "ageRating", client: true, writes: true },
15
+ "review-contact": { mod: "reviewContact", client: true, writes: true },
16
+ accessibility: { mod: "accessibility", client: true, writes: true },
10
17
  privacy: { mod: "privacy", client: false },
11
- previews: { mod: "previews", client: true },
18
+ previews: { mod: "previews", client: true, writes: true },
12
19
  iap: { mod: "iap", client: false },
13
20
  compliance: { mod: "compliance", client: false },
14
21
  inspect: { mod: "inspect", client: true },
15
22
  diff: { mod: "diff", client: true },
16
23
  preflight: { mod: "preflight", client: true },
24
+ // Uploads the .ipa to TestFlight. Needs the credentials as well as the client: the REST API
25
+ // cannot carry a binary, so this one shells out to `xcrun altool`, which authenticates itself.
26
+ prerelease: { mod: "prerelease", client: true, credentials: true, writes: true },
17
27
  };
18
28
 
19
29
  // Commands available for `--store google` (Google Play). Same names as the Apple ones, different backend
@@ -22,9 +32,9 @@ export const PLAY_COMMANDS = {
22
32
  inspect: { mod: "inspect" },
23
33
  preflight: { mod: "preflight" },
24
34
  diff: { mod: "diff" },
25
- fill: { mod: "fill" },
26
- prerelease: { mod: "prerelease" },
35
+ fill: { mod: "fill", writes: true },
36
+ prerelease: { mod: "prerelease", writes: true },
27
37
  };
28
38
 
29
39
  // Full public command surface (the module-dispatched ones above + the three handled inline in bin/).
30
- export const COMMAND_NAMES = [...Object.keys(COMMANDS), "prerelease", "auth", "locales", "version"];
40
+ export const COMMAND_NAMES = [...Object.keys(COMMANDS), "auth", "locales", "version"];
package/src/upload.mjs CHANGED
@@ -33,6 +33,9 @@ export async function uploadAsset(client, { type, setType, setId, filePath }) {
33
33
 
34
34
  // Previews process asynchronously — poll until Apple exposes videoUrl, then set the poster frame.
35
35
  export async function setPreviewPoster(client, previewId, frameTimeCode, { tries = 30, delayMs = 15000 } = {}) {
36
+ // In a dry run the preview was never created, so `previewId` is a synthetic `dry-run-<n>` and this would
37
+ // poll a 404 for seven and a half minutes before giving up.
38
+ if (client.dryRun) return true;
36
39
  for (let i = 0; i < tries; i++) {
37
40
  const { json } = await client.get(`/v1/appPreviews/${previewId}`);
38
41
  if (json.data?.attributes?.videoUrl) {
package/types/index.d.ts CHANGED
@@ -16,7 +16,10 @@ export type CommandName =
16
16
  | 'inspect'
17
17
  | 'diff'
18
18
  | 'preflight'
19
- /** Google Play only: upload an .aab to a closed testing track. Refuses `production`. */
19
+ /**
20
+ * Upload the build for testers: an .ipa to TestFlight (internal groups only), or with
21
+ * `--store google` an .aab to a closed track. Refuses App Store review and `production`.
22
+ */
20
23
  | 'prerelease'
21
24
  | 'auth'
22
25
  | 'locales'
@@ -52,6 +55,38 @@ export interface PrivacyConfig {
52
55
  tracking: boolean;
53
56
  }
54
57
 
58
+ /**
59
+ * Accessibility Nutrition Labels — CLAIMS about your app's behaviour, published to Apple.
60
+ *
61
+ * Every feature is stated explicitly and none is optional: an omission would read as a quiet
62
+ * "no", which is exactly as unverified as a quiet "yes". A missing block is an error rather than
63
+ * a default, because "nobody wrote this down" must never become "supports everything".
64
+ *
65
+ * Apple's platform caveats are applied for you — Larger Text does not exist on macOS, Voice
66
+ * Control does not exist on watchOS — so those are sent as false whatever you declare.
67
+ */
68
+ export interface AccessibilityConfig {
69
+ voiceover: boolean;
70
+ voiceControl: boolean;
71
+ largerText: boolean;
72
+ sufficientContrast: boolean;
73
+ darkInterface: boolean;
74
+ differentiateWithoutColorAlone: boolean;
75
+ reducedMotion: boolean;
76
+ captions: boolean;
77
+ audioDescriptions: boolean;
78
+ }
79
+
80
+ export interface IosConfig {
81
+ /** `.ipa` for `prerelease` — a file, or a directory whose NEWEST .ipa is taken. Override: VYDANNE_IPA. */
82
+ ipa?: string;
83
+ /**
84
+ * INTERNAL TestFlight group to add the uploaded build to. External groups are refused:
85
+ * distributing to them requires Beta App Review, which is a submission by another name.
86
+ */
87
+ testFlightGroup?: string;
88
+ }
89
+
55
90
  export interface GoogleConfig {
56
91
  /** Play package name (usually the same as the iOS bundle id). */
57
92
  packageName: string;
@@ -98,20 +133,38 @@ export interface VydanneConfig {
98
133
  iaps?: IapConfig[];
99
134
  previews?: PreviewSpec[];
100
135
  export?: ExportConfig;
136
+ /** What the app actually supports, for the Accessibility Nutrition Labels. Required by that command. */
137
+ accessibility?: AccessibilityConfig;
138
+ /** iOS build upload (`prerelease`): where the .ipa is, and which internal TestFlight group. */
139
+ ios?: IosConfig;
101
140
  /** Google Play (`--store google`): listings, screenshots, feature graphic via the Edits API. */
102
141
  google?: GoogleConfig;
142
+ /**
143
+ * Terms the cross-store check must not flag for this app.
144
+ *
145
+ * `preflight` and `fill` refuse listing text that names the OTHER mobile platform — App Review
146
+ * guideline 2.3.10 and Google Play's Store Listing and Promotion policy both reject it. Use this
147
+ * only for a word that genuinely belongs in your copy (a game about fruit really does say
148
+ * "apple"); it is not a way to ship a store name.
149
+ */
150
+ allowCrossStoreTerms?: string[];
103
151
  }
104
152
 
105
153
  /** Thin ASC REST client (native fetch + ES256 JWT). */
106
154
  export declare class Client {
107
- constructor(opts: { keyId: string; issuerId: string });
155
+ constructor(opts: { keyId: string; issuerId: string; dryRun?: boolean });
108
156
  token: string;
109
157
  appId?: string;
110
158
  app?: unknown;
159
+ /** When true, no POST/PATCH/PUT/DELETE leaves the process — each is recorded in `planned` instead. */
160
+ dryRun: boolean;
161
+ /** The mutations a real run would have sent, in order. Populated only while `dryRun`. */
162
+ planned: Array<{ method: string; path: string; attributes: Record<string, unknown> }>;
111
163
  findApp(bundleId: string): Promise<unknown>;
112
164
  get(path: string, opts?: { iris?: boolean }): Promise<{ status: number; json: any }>;
113
165
  post(path: string, body: unknown): Promise<{ status: number; json: any }>;
114
166
  patch(path: string, body: unknown): Promise<{ status: number; json: any }>;
167
+ del(path: string): Promise<{ status: number; json: any }>;
115
168
  editVersion(platform: Platform): Promise<any>;
116
169
  appInfo(): Promise<any>;
117
170
  versionLocalizations(versionId: string): Promise<any[]>;
@@ -125,6 +178,7 @@ export declare function toAsc(code: string): string | null;
125
178
  export declare const VALID: Set<string>;
126
179
  export declare const CONFIG_KEYS: readonly string[];
127
180
  export declare const COMMAND_NAMES: readonly CommandName[];
128
- export declare const COMMANDS: Record<string, { mod: string; client: boolean }>;
181
+ /** `writes` marks a command that mutates the STORE those are dry-run unless the CLI gets `--apply`. */
182
+ export declare const COMMANDS: Record<string, { mod: string; client: boolean; writes?: boolean }>;
129
183
 
130
184
  export default VydanneConfig;