vydanne 0.6.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.
package/bin/vydanne.mjs CHANGED
@@ -15,6 +15,14 @@ const i = argv.indexOf("--config");
15
15
  const cfgPath = i >= 0 ? argv[i + 1] : undefined;
16
16
  const si = argv.indexOf("--store");
17
17
  const store = si >= 0 ? argv[si + 1] : "apple";
18
+ // Validated, because the dispatch below only tests `store === "google"`: a typo (`--store goole`) or a
19
+ // missing value (`--store --apply`) would otherwise fall through to the APPLE branch and aim the command
20
+ // at the other store — the worst possible reading of a mistyped argument, and a silent one, since every
21
+ // Apple command runs happily from a repo configured for both.
22
+ if (store !== "apple" && store !== "google") {
23
+ console.error(`\x1b[31mvydanne: unknown --store '${store}' (expected: apple, google)\x1b[0m`);
24
+ process.exit(1);
25
+ }
18
26
 
19
27
  // SAFE BY DEFAULT. Every store-mutating command is a dry run unless `--apply` is passed. The Play half
20
28
  // always worked this way (VYDANNE_COMMIT=1, enforceable because an Edit can be discarded); the Apple half
@@ -57,6 +65,9 @@ try {
57
65
  console.log(`supported (${Object.keys(r.supported).length}):`);
58
66
  for (const [ui, asc] of Object.entries(r.supported)) console.log(` ${ui} -> ${asc}`);
59
67
  console.log(`unsupported (${r.unsupported.length}) [no App Store language -> fall back to primary]: ${r.unsupported.join(", ")}`);
68
+ // A localeMap entry pointing at a code Apple does not have is a config mistake, not a missing
69
+ // language — it would otherwise be indistinguishable from the line above.
70
+ if (r.invalid?.length) console.log(`\x1b[31minvalid localeMap (${r.invalid.length}) [not an App Store code]: ${r.invalid.join(", ")}\x1b[0m`);
60
71
  } else if (store === "google") {
61
72
  const cfg = await loadConfig(cfgPath);
62
73
  if (!cfg.google) throw new Error("vydanne: no `google` block in config — add packageName + a service-account key");
@@ -107,6 +118,15 @@ usage: vydanne <command> [--apply] [--config vydanne.config.mjs]
107
118
  --apply PERFORM the writes. Without it every store-mutating command below (marked ✎) runs
108
119
  as a DRY RUN: it reads the store, reports exactly what it would change, and sends
109
120
  nothing. Read-only commands ignore the flag.
121
+ ✎ prepare create/reuse the editable App Store version and attach the uploaded build.
122
+ Run this FIRST on an app that already has a version on sale — until it has,
123
+ there is no draft for \`fill\` to write into. Never submits; VYDANNE_VERSION=<x>
124
+ to name the version instead of reading it off the newest build.
125
+ ✎ push the whole pipeline, in order: prepare → fill → previews → age-rating →
126
+ review-contact → accessibility → preflight. Stops at the first failure.
127
+ --skip <step>[,<step>] drops steps that don't apply (prepare/preflight can't be
128
+ skipped); every skip is reported again at the end, so green still means green.
129
+ Ends at a green preflight — Add to Review + Submit stay yours, in the web UI.
110
130
  ✎ fill metadata + screenshots + previews (native; iOS & macOS separate)
111
131
  ✎ age-rating set the age rating (AppInfo declaration)
112
132
  ✎ review-contact App Review contact from the gitignored files
@@ -115,6 +135,8 @@ usage: vydanne <command> [--apply] [--config vydanne.config.mjs]
115
135
  ✎ previews upload App Preview videos (native chunked upload)
116
136
  iap validate IAP fields; VYDANNE_FLATTEN=<png> flattens a screenshot to RGB
117
137
  compliance generate the US encryption self-classification PDF
138
+ bridge map zdymak's store-assets output onto the folders \`fill\` reads (locale codes,
139
+ device prefixes, Play paths). Local files only; --dry-run previews.
118
140
  inspect read-only ASC state
119
141
  diff show what differs between local (metadata/screenshots/previews) and ASC
120
142
  preflight verify submission-completeness (the gotcha checker)
@@ -125,7 +147,8 @@ usage: vydanne <command> [--apply] [--config vydanne.config.mjs]
125
147
  credentials: env > .env cascade (.env, .env.<mode>, .env.local, .env.<mode>.local) > user config
126
148
  (\$VYDANNE_CONFIG_HOME, %APPDATA%\\vydanne or \$XDG_CONFIG_HOME/vydanne, ~/.appstoreconnect).
127
149
  NEVER the committed vydanne.config.mjs — run \`vydanne auth\` to see what resolved.
128
- toggles: VYDANNE_SKIP_METADATA / VYDANNE_SKIP_SCREENSHOTS (fill), VYDANNE_A11Y_PUBLISH (accessibility),
150
+ toggles: VYDANNE_SKIP_METADATA / VYDANNE_SKIP_SCREENSHOTS (fill), VYDANNE_REPLACE=1 (fill/previews:
151
+ replace populated slots), VYDANNE_VERSION (prepare), VYDANNE_A11Y_PUBLISH (accessibility),
129
152
  VYDANNE_PROFILE (named profile), VYDANNE_ENV (.env mode),
130
153
  VYDANNE_COMMIT=1 (legacy alias for --apply — prefer the flag)`;
131
154
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vydanne",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "App Store Connect + Google Play submission prep — the companion to zdymak (media). Native Node (no fastlane/Ruby/Python): localized listings, screenshot/preview/icon upload, ratings, review contact, accessibility & privacy labels, IAP, export docs, build upload to TestFlight / a Play closed track, a diff of local-vs-store, and a preflight verifier that encodes the store gotchas. Never submits for review. iOS/macOS via the ASC REST API; Android via the Play Developer Edits API (--store google).",
5
5
  "keywords": [
6
6
  "app-store-connect",
@@ -51,7 +51,8 @@
51
51
  "scripts": {
52
52
  "check:docs": "node scripts/check-docs.mjs",
53
53
  "check:types": "tsc --noEmit -p tsconfig.json && node scripts/check-types.mjs",
54
- "prepublishOnly": "npm run check:docs && npm run check:types",
54
+ "check:config": "node scripts/check-config.mjs",
55
+ "prepublishOnly": "npm run check:docs && npm run check:types && npm run check:config",
55
56
  "release:patch": "node scripts/release.mjs patch",
56
57
  "release:minor": "node scripts/release.mjs minor",
57
58
  "release:major": "node scripts/release.mjs major"
package/src/client.mjs CHANGED
@@ -3,13 +3,15 @@ import { yellow } from "./util.mjs";
3
3
 
4
4
  const API = "https://api.appstoreconnect.apple.com";
5
5
  const IRIS = "https://appstoreconnect.apple.com/iris";
6
- const DEAD_VERSION = ["READY_FOR_SALE", "REMOVED_FROM_SALE", "REPLACED_WITH_NEW_VERSION"];
6
+ // DEVELOPER_REMOVED_FROM_SALE is the state a version reaches when the DEVELOPER pulls it, as opposed to
7
+ // Apple; both are shipped versions that will never become editable again, so both are dead here.
8
+ const DEAD_VERSION = ["READY_FOR_SALE", "REMOVED_FROM_SALE", "DEVELOPER_REMOVED_FROM_SALE", "REPLACED_WITH_NEW_VERSION"];
7
9
  // READY_FOR_DISTRIBUTION is Apple's newer name for the LIVE app-info (they're migrating the
8
10
  // state vocabulary; versions still report appStoreState=READY_FOR_SALE). Without it here,
9
11
  // appInfo() treats the live record as editable and every name/subtitle PATCH comes back
10
12
  // ENTITY_ERROR.ATTRIBUTE.INVALID.INVALID_STATE — which fails the whole locale in `fill`,
11
13
  // release notes included, on any app that already has a version on sale.
12
- const DEAD_INFO = ["READY_FOR_SALE", "READY_FOR_DISTRIBUTION", "REPLACED_WITH_NEW_VERSION", "REMOVED_FROM_SALE"];
14
+ const DEAD_INFO = ["READY_FOR_SALE", "READY_FOR_DISTRIBUTION", "REPLACED_WITH_NEW_VERSION", "REMOVED_FROM_SALE", "DEVELOPER_REMOVED_FROM_SALE"];
13
15
 
14
16
  // Anything that is not a read. ASC has no transaction to roll back — unlike Play, where an edit can be
15
17
  // discarded — so for Apple the only safe place to stand between a command and a live listing is here.
@@ -79,16 +81,50 @@ export class Client {
79
81
  return app;
80
82
  }
81
83
 
82
- async editVersion(platform) {
84
+ /**
85
+ * The version being PREPARED — or null when there isn't one.
86
+ *
87
+ * This used to fall back to `data[0]`, the LIVE version, whenever nothing editable existed. The
88
+ * fallback was silent and it defeated its own callers: `fill`, `preflight`, `reviewContact` and
89
+ * `previews` each test `if (!v)` and report "no editable version", and not one of those branches could
90
+ * be reached on an app that had shipped once. What happened instead was worse than an error — `fill`
91
+ * aimed its description/whatsNew PATCHes at the listing customers were reading, and `preflight`
92
+ * validated that same live listing and printed "no blockers", calling a release submittable when
93
+ * there was nothing to submit.
94
+ *
95
+ * So a write is never handed the live version by default. Read-only commands (`inspect`, `diff`) pass
96
+ * `allowLive: true`, because "how does local compare with what is on sale" is a real question and that
97
+ * is the only version they could ask it about. Everything else gets null and says so — and now has
98
+ * `prepare` to point at, which is the command that makes an editable version exist.
99
+ */
100
+ async editVersion(platform, { allowLive = false } = {}) {
83
101
  const { json } = await this.get(`/v1/apps/${this.appId}/appStoreVersions?filter[platform]=${platform}&limit=10`);
84
102
  const data = json.data || [];
85
- return data.find((v) => !DEAD_VERSION.includes(v.attributes.appStoreState)) || data[0] || null;
103
+ const editable = data.find((v) => !DEAD_VERSION.includes(v.attributes.appStoreState));
104
+ if (editable) return editable;
105
+ return allowLive ? data[0] || null : null;
86
106
  }
87
107
 
88
- async appInfo() {
108
+ /**
109
+ * The app-info being PREPARED (name/subtitle, age rating live on it) — or null when there isn't one.
110
+ *
111
+ * Same disease, same cure as editVersion() above: this fell back to `data[0]` — the LIVE app-info —
112
+ * whenever nothing editable existed, and the fallback aimed writes at the record customers see. It is
113
+ * how the DEAD_INFO bug was found in the first place (every name/subtitle PATCH against the live
114
+ * record comes back INVALID_STATE and fails the whole locale in `fill`), and adding
115
+ * READY_FOR_DISTRIBUTION to DEAD_INFO only fixed the case where an editable sibling EXISTS to be
116
+ * found; the moment there is none, `|| data[0]` reintroduced exactly the state that comment warns
117
+ * about. Now a write gets null and a clear skip instead of twenty locales of ENTITY_ERROR.
118
+ *
119
+ * `allowLive` is for reads (`diff`), where "how does local compare with what is on sale" is the
120
+ * question being asked.
121
+ */
122
+ async appInfo({ allowLive = false } = {}) {
89
123
  const { json } = await this.get(`/v1/apps/${this.appId}/appInfos?limit=10`);
90
124
  const data = json.data || [];
91
- return data.find((i) => !DEAD_INFO.includes(i.attributes.state)) || data[0] || null;
125
+ const editable = data.find((i) => !DEAD_INFO.includes(i.attributes.state));
126
+ if (editable) return editable;
127
+ return allowLive ? data[0] || null : null;
92
128
  }
93
129
 
94
130
  async versionLocalizations(versionId) {
@@ -107,8 +107,14 @@ export async function run(config, client) {
107
107
  console.log(` declaring: ${claimed.length ? claimed.join(", ") : "(nothing)"}`);
108
108
 
109
109
  let gated = false;
110
+ // A PATCH that Apple refuses is a claim that never reached the store. Both failure paths below used
111
+ // to print red and `continue`, and the command still returned true — so `push` treated a family whose
112
+ // declaration never saved as a completed step. The `continue`s stay (one refused family must not hide
113
+ // the other three); the verdict now travels out with the return.
114
+ const failures = [];
110
115
  for (const family of Object.keys(UNAVAILABLE)) {
111
116
  const id = decls[family];
117
+ // Not a failure: Apple only holds declarations for the families the app actually ships on.
112
118
  if (!id) {
113
119
  console.error(yellow(` no ${family} declaration`));
114
120
  continue;
@@ -119,6 +125,7 @@ export async function run(config, client) {
119
125
  });
120
126
  if (r.status >= 300) {
121
127
  console.error(red(` ${family} draft ${r.status}`));
128
+ failures.push(`${family}: draft not saved (${r.status})`);
122
129
  continue;
123
130
  }
124
131
  if (publish) {
@@ -132,11 +139,17 @@ export async function run(config, client) {
132
139
  console.log(yellow(` ${family}: draft saved — publish deferred (app not live yet)`));
133
140
  } else {
134
141
  console.error(red(` ${family} publish ${p.status}`));
142
+ failures.push(`${family}: publish refused (${p.status})`);
135
143
  }
136
144
  } else {
137
145
  console.log(green(` ${family}: draft saved`));
138
146
  }
139
147
  }
148
+ if (failures.length) {
149
+ console.error(red(`accessibility: ${failures.length} declaration(s) did not save:`));
150
+ for (const f of failures) console.error(` ${red("x")} ${f}`);
151
+ return false;
152
+ }
140
153
  console.log(
141
154
  gated
142
155
  ? yellow("accessibility staged (DRAFT); re-run with VYDANNE_A11Y_PUBLISH=1 once the app is live")
@@ -1,33 +1,106 @@
1
1
  import { green, yellow, red } from "../util.mjs";
2
2
 
3
- // 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).
3
+ /**
4
+ * Set the age rating via the AppInfo age-rating declaration.
5
+ *
6
+ * `rating: "4+"` stays the shorthand it always was: every content descriptor NONE, every capability
7
+ * question false, which is what Apple recomputes 4+ from. Anything else is declared feature by feature
8
+ * in `ageRating`, merged over that all-NONE base — so an app with fantasy violence sets one key rather
9
+ * than restating twenty-seven.
10
+ *
11
+ * This used to refuse outright for any rating but 4+, which made the whole command — and `push`, which
12
+ * runs it as a step — unusable for any app with violence, gambling, chat or user-generated content.
13
+ * The base was always the general thing; only the door was missing.
14
+ */
15
+
16
+ const N = "NONE";
17
+
18
+ // Apple's 2025 age-rating schema. A PATCH must include ALL required fields (a partial set 409s), and
19
+ // `ageRatingOverride` (deprecated) cannot be sent alongside `ageRatingOverrideV2` — so we send only V2.
20
+ // Content descriptors are enums (NONE/INFREQUENT_OR_MILD/FREQUENT_OR_INTENSE); capability questions are
21
+ // booleans. All benign here → 4+.
22
+ const BASE = {
23
+ advertising: false, alcoholTobaccoOrDrugUseOrReferences: N, contests: N, gambling: false,
24
+ gamblingSimulated: N, gunsOrOtherWeapons: N, healthOrWellnessTopics: false, kidsAgeBand: null,
25
+ lootBox: false, medicalOrTreatmentInformation: N, messagingAndChat: false, parentalControls: false,
26
+ profanityOrCrudeHumor: N, ageAssurance: false, sexualContentGraphicAndNudity: N, sexualContentOrNudity: N,
27
+ socialMedia: false, socialMediaAgeRestricted: false, horrorOrFearThemes: N, matureOrSuggestiveThemes: N,
28
+ unrestrictedWebAccess: false, userGeneratedContent: false, violenceCartoonOrFantasy: N,
29
+ violenceRealisticProlongedGraphicOrSadistic: N, violenceRealistic: N, ageRatingOverrideV2: N,
30
+ koreaAgeRatingOverride: N,
31
+ };
32
+
33
+ /** Values Apple accepts for a content descriptor. `kidsAgeBand` and the overrides are checked separately. */
34
+ const DESCRIPTOR_VALUES = new Set([N, "INFREQUENT_OR_MILD", "FREQUENT_OR_INTENSE"]);
35
+
36
+ /**
37
+ * The attributes to send, or a human-readable problem.
38
+ *
39
+ * A declaration is validated against the schema BEFORE it reaches Apple, because the failure otherwise
40
+ * is a 409 naming a field the operator did not know existed. An unknown key is a typo — and a typo in
41
+ * this table means a descriptor silently stayed NONE, which is a rating that understates the app.
42
+ */
43
+ export function resolveAttributes(config) {
44
+ const declared = config.ageRating;
45
+ if (!declared) {
46
+ if (config.rating === "4+") return { attributes: { ...BASE } };
47
+ return {
48
+ problem: [
49
+ `age-rating: rating is '${config.rating}', but nothing describes what makes it that.`,
50
+ "'4+' is the only rating that needs no detail (every descriptor NONE). For anything else,",
51
+ "declare the content Apple asks about — it computes the rating from these, you don't set it:",
52
+ "",
53
+ " ageRating: {",
54
+ " violenceCartoonOrFantasy: 'INFREQUENT_OR_MILD',",
55
+ " userGeneratedContent: false,",
56
+ " },",
57
+ "",
58
+ `Known keys: ${Object.keys(BASE).join(", ")}`,
59
+ ].join("\n"),
60
+ };
61
+ }
62
+ const unknown = Object.keys(declared).filter((k) => !(k in BASE));
63
+ if (unknown.length) {
64
+ return { problem: `age-rating: unknown key(s) ${unknown.join(", ")}. Known: ${Object.keys(BASE).join(", ")}` };
65
+ }
66
+ const bad = [];
67
+ for (const [k, v] of Object.entries(declared)) {
68
+ if (k === "kidsAgeBand") continue; // null | FIVE_AND_UNDER | SIX_TO_EIGHT | NINE_TO_ELEVEN
69
+ if (typeof BASE[k] === "boolean" && typeof v !== "boolean") bad.push(`${k} must be true or false`);
70
+ else if (typeof BASE[k] === "string" && !DESCRIPTOR_VALUES.has(v)) bad.push(`${k} must be one of ${[...DESCRIPTOR_VALUES].join(" / ")}`);
71
+ }
72
+ if (bad.length) return { problem: `age-rating: ${bad.join("; ")}` };
73
+ return { attributes: { ...BASE, ...declared } };
74
+ }
75
+
6
76
  export async function run(config, client) {
7
- if (config.rating !== "4+") { console.error(yellow(`age-rating: only '4+' (all-NONE) implemented; config=${config.rating}`)); return false; }
77
+ const { attributes, problem } = resolveAttributes(config);
78
+ if (problem) { console.error(red(problem)); return false; }
8
79
  await client.findApp(config.bundleId);
80
+ // No allowLive: this PATCHes the age-rating declaration, and the live app-info's declaration is not
81
+ // ours to aim a write at. The refusal below used to be unreachable — appInfo() handed back the live
82
+ // record instead of null, so the write was planned against it and Apple's INVALID_STATE was the
83
+ // first anyone heard of it.
9
84
  const info = await client.appInfo();
10
- if (!info) { console.error(red("age-rating: no editable app info")); return false; }
85
+ if (!info) {
86
+ console.error(red("age-rating: no editable app info — refusing to write to the live record."));
87
+ console.error(" `vydanne prepare --apply` starts the next version, which makes app info editable again.");
88
+ return false;
89
+ }
11
90
  const { json } = await client.get(`/v1/appInfos/${info.id}/ageRatingDeclaration`);
12
91
  const id = json.data?.id;
13
92
  if (!id) { console.error(red("age-rating: no declaration")); return false; }
14
- 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
- };
93
+
94
+ // What is actually being asserted, printed before it is sent. Apple computes the rating from these,
95
+ // so the declared non-defaults ARE the rating — worth seeing in the log of the run that set them.
96
+ const declared = Object.entries(attributes).filter(([k, v]) => v !== BASE[k] || (v !== false && v !== N && v !== null));
97
+ console.log(` declaring: ${declared.length ? declared.map(([k, v]) => `${k}=${v}`).join(", ") : "everything NONE (4+)"}`);
98
+
29
99
  const r = await client.patch(`/v1/ageRatingDeclarations/${id}`, { data: { type: "ageRatingDeclarations", id, attributes } });
30
100
  if (r.status >= 300) { console.error(red(`age-rating: ${r.status}: ${JSON.stringify(r.json).slice(0, 200)}`)); return false; }
31
- console.log(client.dryRun ? yellow("age rating WOULD be set -> 4+") : green("age rating set -> 4+"));
101
+ // Apple decides the band from the descriptors; naming config.rating here reports what was ASKED for,
102
+ // which is the only thing this command controls.
103
+ const label = config.ageRating ? `${config.rating} (from the declared descriptors)` : "4+";
104
+ console.log(client.dryRun ? yellow(`age rating WOULD be set -> ${label}`) : green(`age rating set -> ${label}`));
32
105
  return true;
33
106
  }