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.
@@ -2,26 +2,96 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { green, yellow } from "../util.mjs";
4
4
 
5
- // App Privacy is on Apple's iris host, which 401s the JWT — it CANNOT be set with the ASC API key. Write
6
- // the honest record + print the exact ASC-UI answers (a passkey login works there).
7
- const GROUP = { CRASH_DATA: "Diagnostics", PERFORMANCE_DATA: "Diagnostics", OTHER_DIAGNOSTIC_DATA: "Diagnostics" };
5
+ /**
6
+ * App Privacy is on Apple's iris host, which 401s the ASC API key — it CANNOT be set through the API.
7
+ * So this writes the honest record and prints the exact answers to type into the ASC UI (where a
8
+ * passkey login works).
9
+ *
10
+ * PURPOSES ARE DECLARED, NOT ASSUMED. Every collected category used to be reported as
11
+ * "APP_FUNCTIONALITY", which is right for crash data in a game and wrong for almost anything else — an
12
+ * email address collected for marketing, or location used for analytics, would have been printed as
13
+ * App Functionality and typed into Apple's form that way. And the UI group was known only for the three
14
+ * diagnostics categories, so anything else printed a literal "?" as its section heading, which is not
15
+ * an answer anyone can enter. Both are now data tables covering Apple's full vocabulary, with
16
+ * per-category overrides in the config for the app that needs them.
17
+ */
18
+
19
+ /** Apple's data categories -> the section they live under in the App Privacy form. */
20
+ const GROUP = {
21
+ // Contact info
22
+ NAME: "Contact Info", EMAIL_ADDRESS: "Contact Info", PHONE_NUMBER: "Contact Info",
23
+ PHYSICAL_ADDRESS: "Contact Info", OTHER_USER_CONTACT_INFO: "Contact Info",
24
+ // Health & fitness
25
+ HEALTH: "Health & Fitness", FITNESS: "Health & Fitness",
26
+ // Financial info
27
+ PAYMENT_INFO: "Financial Info", CREDIT_INFO: "Financial Info", OTHER_FINANCIAL_INFO: "Financial Info",
28
+ // Location
29
+ PRECISE_LOCATION: "Location", COARSE_LOCATION: "Location",
30
+ // Sensitive info
31
+ SENSITIVE_INFO: "Sensitive Info",
32
+ // Contacts
33
+ CONTACTS: "Contacts",
34
+ // User content
35
+ EMAILS_OR_TEXT_MESSAGES: "User Content", PHOTOS_OR_VIDEOS: "User Content", AUDIO_DATA: "User Content",
36
+ GAMEPLAY_CONTENT: "User Content", CUSTOMER_SUPPORT: "User Content", OTHER_USER_CONTENT: "User Content",
37
+ // Browsing / search history
38
+ BROWSING_HISTORY: "Browsing History", SEARCH_HISTORY: "Search History",
39
+ // Identifiers
40
+ USER_ID: "Identifiers", DEVICE_ID: "Identifiers",
41
+ // Purchases
42
+ PURCHASE_HISTORY: "Purchases",
43
+ // Usage data
44
+ PRODUCT_INTERACTION: "Usage Data", ADVERTISING_DATA: "Usage Data", OTHER_USAGE_DATA: "Usage Data",
45
+ // Diagnostics
46
+ CRASH_DATA: "Diagnostics", PERFORMANCE_DATA: "Diagnostics", OTHER_DIAGNOSTIC_DATA: "Diagnostics",
47
+ // Other
48
+ OTHER_DATA_TYPES: "Other Data",
49
+ };
50
+
51
+ /** Apple's purpose codes -> the label the form uses. */
52
+ const PURPOSE_LABEL = {
53
+ THIRD_PARTY_ADVERTISING: "Third-Party Advertising",
54
+ DEVELOPERS_ADVERTISING: "Developer's Advertising or Marketing",
55
+ ANALYTICS: "Analytics",
56
+ PRODUCT_PERSONALIZATION: "Product Personalization",
57
+ APP_FUNCTIONALITY: "App Functionality",
58
+ OTHER_PURPOSES: "Other Purposes",
59
+ };
8
60
 
9
61
  export async function run(config) {
10
62
  const collected = config.privacy?.collected || ["CRASH_DATA", "PERFORMANCE_DATA"];
11
63
  const tracking = !!config.privacy?.tracking;
64
+ // `purposes` may be one list for everything, or a per-category map. App Functionality stays the
65
+ // default because it is the honest answer for the diagnostics an app collects to fix itself — but it
66
+ // is now a default that an app can disagree with, rather than the only thing this command can say.
67
+ const declared = config.privacy?.purposes;
68
+ const purposesFor = (cat) => {
69
+ const p = Array.isArray(declared) ? declared : declared?.[cat];
70
+ return (p && p.length ? p : ["APP_FUNCTIONALITY"]);
71
+ };
72
+
12
73
  const record = collected.map((cat) => ({
13
- category: cat, purposes: ["APP_FUNCTIONALITY"],
74
+ category: cat,
75
+ purposes: purposesFor(cat),
14
76
  data_protections: [tracking ? "DATA_LINKED_TO_YOU" : "DATA_NOT_LINKED_TO_YOU", tracking ? "DATA_USED_TO_TRACK_YOU" : null].filter(Boolean),
15
77
  }));
16
78
  const out = path.join(path.dirname(config.metadataDir), "app_privacy_details.json");
17
79
  fs.writeFileSync(out, JSON.stringify(record, null, 2));
18
80
  console.log(green(`wrote ${out} (declaration record)`));
19
81
  console.log();
82
+
83
+ const unknown = collected.filter((c) => !GROUP[c]);
84
+ if (unknown.length) {
85
+ console.log(yellow(` category not in Apple's published list, section unknown: ${unknown.join(", ")}`));
86
+ console.log(" Check the spelling against App Privacy in App Store Connect before entering it.");
87
+ }
88
+
20
89
  console.log(yellow("App Privacy is UI-only (passkey) — the API key 401s on Apple's iris host. Enter:"));
21
90
  if (collected.length) console.log(" Do you or your partners collect data? -> Yes");
22
91
  for (const c of collected) {
23
92
  const name = c.split("_").map((w) => w[0] + w.slice(1).toLowerCase()).join(" ");
24
- console.log(` ${GROUP[c] || "?"} -> ${name}: App Functionality · ${tracking ? "Linked" : "Not Linked"} · ${tracking ? "Tracking" : "No Tracking"}`);
93
+ const purposes = purposesFor(c).map((p) => PURPOSE_LABEL[p] || p).join(" + ");
94
+ console.log(` ${GROUP[c] || "?"} -> ${name}: ${purposes} · ${tracking ? "Linked" : "Not Linked"} · ${tracking ? "Tracking" : "No Tracking"}`);
25
95
  }
26
96
  console.log(" Everything else -> Not Collected.");
27
97
  console.log(yellow(" 'accesses' is not 'collects' — E2EE content you can't read is not collected."));
@@ -0,0 +1,118 @@
1
+ import { green, yellow, red } from "../util.mjs";
2
+ import { run as prepare } from "./prepare.mjs";
3
+ import { run as fill } from "./fill.mjs";
4
+ import { run as previews } from "./previews.mjs";
5
+ import { run as ageRating } from "./ageRating.mjs";
6
+ import { run as reviewContact } from "./reviewContact.mjs";
7
+ import { run as accessibility } from "./accessibility.mjs";
8
+ import { run as preflight } from "./preflight.mjs";
9
+
10
+ /**
11
+ * The whole Apple release pipeline, in the one order that works.
12
+ *
13
+ * WHY A COMMAND AND NOT A PARAGRAPH. Shipping an update takes seven commands in a specific order —
14
+ * `prepare` first (or nothing has a version to write into), `preflight` last (or green is measured
15
+ * before the writes it is meant to bless) — and that ordering lived in nobody's head. The near-miss
16
+ * that proved it: with the order forgotten, `fill` was pointed at an app whose only version was ON
17
+ * SALE, and only the editVersion() fix stood between the dry run and a rewritten live listing. A
18
+ * README section can teach the order; only a command can make it impossible to run out of order.
19
+ *
20
+ * WHAT IT IS. A sequencer, nothing more: each step is the same `run` its standalone command uses, on
21
+ * the same client, so `push` can do nothing a step couldn't. The first failure stops the run — a step
22
+ * that cannot proceed says why and names its fix (that is each command's own contract), and continuing
23
+ * past it would let a green `preflight` at the end bless a release with a known hole in it.
24
+ *
25
+ * SKIPPING IS EXPLICIT AND LOUD. `--skip <step>[,<step>]` (or `push: { skip: [...] }`) drops a step
26
+ * from the run. This exists because a pipeline with no way out is a pipeline that stops working the
27
+ * moment one step legitimately does not apply to an app: `accessibility` refuses when no block is
28
+ * declared — correctly, it publishes claims — so an app that has not audited its accessibility could
29
+ * not complete `push` at all, and neither could one with no App Review PII on disk. The skip is named
30
+ * on every line of the report and again at the end, because the whole value of the last step is that
31
+ * green means green: a run that skipped something must never read like a run that did everything.
32
+ *
33
+ * WHAT IT IS NOT. It never submits — the pipeline ends at `preflight`, and Add to Review + Submit
34
+ * stay in App Store Connect, human-only, exactly as every step's own refusals already guarantee.
35
+ * `prerelease` (the build upload) is deliberately not a step: it is macOS-only, shells out to altool,
36
+ * and belongs wherever the archive is produced. Run it whenever the build is ready — before or after
37
+ * `push`; `prepare` attaches the newest build either way.
38
+ *
39
+ * Dry-run composes: without --apply the shared client refuses every mutation, each step reports its
40
+ * plan, and bin/ prints the withheld-write count at the end. The one case a dry run cannot preview
41
+ * past is an app with no editable version — the draft the later steps target does not exist until
42
+ * `prepare` is APPLIED — so that is said up front rather than discovered as a refusal mid-run.
43
+ */
44
+ const STEPS = [
45
+ ["prepare", prepare],
46
+ ["fill", fill],
47
+ ["previews", previews],
48
+ ["age-rating", ageRating],
49
+ ["review-contact", reviewContact],
50
+ ["accessibility", accessibility],
51
+ ["preflight", preflight],
52
+ ];
53
+
54
+ /** Steps named by `--skip a,b` and/or `push.skip` in the config, validated against the real step list. */
55
+ function resolveSkips(config) {
56
+ const flag = process.argv.find((a) => a.startsWith("--skip="))?.slice("--skip=".length)
57
+ ?? (process.argv.includes("--skip") ? process.argv[process.argv.indexOf("--skip") + 1] : null);
58
+ const named = [
59
+ ...(flag ? String(flag).split(",") : []),
60
+ ...(config.push?.skip || []),
61
+ ].map((s) => s.trim()).filter(Boolean);
62
+ const names = STEPS.map(([n]) => n);
63
+ const unknown = named.filter((s) => !names.includes(s));
64
+ return { skip: new Set(named.filter((s) => names.includes(s))), unknown };
65
+ }
66
+
67
+ export async function run(config, client) {
68
+ await client.findApp(config.bundleId);
69
+ const { skip, unknown } = resolveSkips(config);
70
+ if (unknown.length) {
71
+ console.error(red(`push: --skip named step(s) that do not exist: ${unknown.join(", ")}`));
72
+ console.error(` Steps: ${STEPS.map(([n]) => n).join(", ")}`);
73
+ return false;
74
+ }
75
+ // Skipping the last step would make the command's own promise unverifiable, and skipping the first
76
+ // leaves every later step writing into a version that may not exist. Both are refusals rather than
77
+ // warnings: there is no reading of "push, but don't check the result" that is worth supporting.
78
+ for (const required of ["prepare", "preflight"]) {
79
+ if (skip.has(required)) {
80
+ console.error(red(`push: \`${required}\` cannot be skipped — run the other steps individually if that is what you want.`));
81
+ return false;
82
+ }
83
+ }
84
+
85
+ const platform = (config.platforms && config.platforms[0]) || "IOS";
86
+ if (client.dryRun && !(await client.editVersion(platform))) {
87
+ console.log(yellow("push: no editable App Store version exists, so this dry run will stop where the draft is needed."));
88
+ console.log(" `vydanne prepare --apply` creates it — a draft, not a submission — then re-run `vydanne push`");
89
+ console.log(" to preview the rest of the pipeline.");
90
+ }
91
+
92
+ const skipped = [];
93
+ for (const [name, step] of STEPS) {
94
+ if (skip.has(name)) {
95
+ console.log(yellow(`\n▸ push: ${name} — SKIPPED (--skip)`));
96
+ skipped.push(name);
97
+ continue;
98
+ }
99
+ console.log(green(`\n▸ push: ${name}`));
100
+ const ok = await step(config, client);
101
+ if (ok === false) {
102
+ console.error(red(`\npush: stopped at \`${name}\` — fix what it reported above, then re-run \`vydanne push\`.`));
103
+ console.error(" Every completed step is idempotent, so re-running repeats nothing destructive.");
104
+ if (name === "age-rating" && !config.ageRating && config.rating !== "4+") {
105
+ console.error(` For a '${config.rating}' rating, declare the content descriptors in \`ageRating\` — see the message above.`);
106
+ }
107
+ console.error(` Or drop this step for now: \`vydanne push --apply --skip ${name}\``);
108
+ return false;
109
+ }
110
+ }
111
+
112
+ console.log(green("\npush done — the release is staged and preflight is green."));
113
+ // Repeated at the end on purpose. The one thing this command sells is that its last line means the
114
+ // release is ready; a skipped step is precisely the case where that would otherwise overstate it.
115
+ if (skipped.length) console.log(yellow(` NOT green for: ${skipped.join(", ")} — skipped, never run. Run them before you submit.`));
116
+ console.log(yellow(" Submitting stays yours: App Store Connect → the prepared version → Add to Review → Submit."));
117
+ return true;
118
+ }
@@ -2,27 +2,77 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { green, yellow, red } from "../util.mjs";
4
4
 
5
- // App Review contact from the GITIGNORED metadata/review_information/*.txt. PATCH (or POST) the review
6
- // detail directly — deliver can't do this cleanly pre-first-submission.
5
+ /**
6
+ * App Review contact, from the GITIGNORED `<metadataDir>/review_information/*.txt`. PATCH (or POST) the
7
+ * review detail directly — deliver can't do this cleanly pre-first-submission.
8
+ *
9
+ * DEMO ACCOUNT. `demoAccountRequired` used to be hardcoded false. That is true of an account-less game
10
+ * and false of anything with a login, and getting it wrong is not a small thing: telling Apple no demo
11
+ * account is needed for an app whose first screen is a sign-in wall is a guaranteed rejection, with a
12
+ * review cycle attached. fastlane's own convention already puts `demo_user.txt` / `demo_password.txt`
13
+ * in the very directory this reads from, so the fix is to read the two files that were sitting there.
14
+ *
15
+ * Every platform gets the same contact, because the contact is a property of the submitter rather than
16
+ * of a platform — an app shipping iOS and macOS used to set it on `platforms[0]` and leave the other
17
+ * version's review detail empty.
18
+ */
7
19
  export async function run(config, client) {
8
20
  const dir = path.join(config.metadataDir, "review_information");
9
21
  const read = (n) => { const p = path.join(dir, `${n}.txt`); return fs.existsSync(p) ? fs.readFileSync(p, "utf8").trim() : ""; };
22
+
23
+ const demoUser = read("demo_user");
24
+ const demoPassword = read("demo_password");
10
25
  const attributes = {
11
26
  contactFirstName: read("first_name"), contactLastName: read("last_name"),
12
27
  contactPhone: read("phone_number"), contactEmail: read("email_address"),
13
- demoAccountRequired: false, notes: read("notes"),
28
+ // Declared by what is on disk, with an explicit config override for the app that needs one and
29
+ // supplies it some other way (a reviewer-specific build, a magic link in the notes).
30
+ demoAccountRequired: config.reviewContact?.demoAccountRequired ?? !!demoUser,
31
+ notes: read("notes"),
14
32
  };
33
+ if (demoUser) attributes.demoAccountName = demoUser;
34
+ if (demoPassword) attributes.demoAccountPassword = demoPassword;
35
+
15
36
  if (!attributes.contactEmail) { console.error(red(`review-contact: ${dir}/*.txt missing`)); return false; }
37
+ // A demo account that is required but has no credentials is the rejection this command exists to
38
+ // avoid, arriving by a different door — say it here rather than letting Apple say it in a week.
39
+ if (attributes.demoAccountRequired && !demoUser) {
40
+ console.error(red("review-contact: demoAccountRequired is true but no demo_user.txt was found."));
41
+ console.error(` Add ${dir}/demo_user.txt and demo_password.txt (both gitignored), or set`);
42
+ console.error(" reviewContact: { demoAccountRequired: false } if the reviewer genuinely needs no account.");
43
+ return false;
44
+ }
45
+
16
46
  await client.findApp(config.bundleId);
17
- const v = await client.editVersion(config.platforms[0]);
18
- if (!v) { console.error(red("review-contact: no editable version")); return false; }
19
- const { json } = await client.get(`/v1/appStoreVersions/${v.id}/appStoreReviewDetail`);
20
- const existing = json.data;
21
- const r = existing?.id
22
- ? await client.patch(`/v1/appStoreReviewDetails/${existing.id}`, { data: { type: "appStoreReviewDetails", id: existing.id, attributes } })
23
- : await client.post(`/v1/appStoreReviewDetails`, { data: { type: "appStoreReviewDetails", attributes, relationships: { appStoreVersion: { data: { type: "appStoreVersions", id: v.id } } } } });
24
- if (r.status >= 300) { console.error(red(`review-contact: ${r.status}: ${JSON.stringify(r.json).slice(0, 200)}`)); return false; }
47
+
48
+ let ok = true;
49
+ let touched = 0;
50
+ for (const platform of config.platforms) {
51
+ const v = await client.editVersion(platform);
52
+ if (!v) {
53
+ console.error(red(`review-contact ${platform}: no editable version — nothing to attach the contact to.`));
54
+ console.error(" `vydanne prepare --apply` creates the version being submitted, then re-run this.");
55
+ ok = false;
56
+ continue;
57
+ }
58
+ const { json } = await client.get(`/v1/appStoreVersions/${v.id}/appStoreReviewDetail`);
59
+ const existing = json.data;
60
+ const r = existing?.id
61
+ ? await client.patch(`/v1/appStoreReviewDetails/${existing.id}`, { data: { type: "appStoreReviewDetails", id: existing.id, attributes } })
62
+ : await client.post(`/v1/appStoreReviewDetails`, { data: { type: "appStoreReviewDetails", attributes, relationships: { appStoreVersion: { data: { type: "appStoreVersions", id: v.id } } } } });
63
+ if (r.status >= 300) {
64
+ console.error(red(`review-contact ${platform}: ${r.status}: ${JSON.stringify(r.json).slice(0, 200)}`));
65
+ ok = false;
66
+ continue;
67
+ }
68
+ touched++;
69
+ }
70
+ if (!touched) return false;
71
+
25
72
  const who = `${attributes.contactFirstName} ${attributes.contactLastName} · ${attributes.contactPhone}`;
26
- console.log(client.dryRun ? yellow(`review contact WOULD be set -> ${who}`) : green(`review contact set -> ${who}`));
27
- return true;
73
+ const demo = attributes.demoAccountRequired ? ` · demo account ${demoUser}` : "";
74
+ console.log(client.dryRun
75
+ ? yellow(`review contact WOULD be set on ${touched} platform(s) -> ${who}${demo}`)
76
+ : green(`review contact set on ${touched} platform(s) -> ${who}${demo}`));
77
+ return ok;
28
78
  }
package/src/config.mjs CHANGED
@@ -3,10 +3,12 @@ import fs from "node:fs";
3
3
  import { pathToFileURL } from "node:url";
4
4
  import { resolveLocales } from "./locales.mjs";
5
5
  import { resolveCredentials } from "./credentials.mjs";
6
+ import { DEFAULT_SCREENSHOT_BASE } from "./screenshots.mjs";
7
+ import { DEFAULT_PLAY_IMAGES } from "./play/images.mjs";
6
8
 
7
9
  // The public config surface — the drift guards assert each key is documented (README/SKILL) and typed
8
10
  // (types/index.d.ts). Add a config knob → document + type it, or the guards fail before publish.
9
- export const CONFIG_KEYS = ["bundleId", "primaryLocale", "asc", "platforms", "uiLocales", "metadataDir", "rating", "privacy", "iaps", "previews", "export", "ios", "google", "accessibility", "allowCrossStoreTerms"];
11
+ export const CONFIG_KEYS = ["bundleId", "primaryLocale", "asc", "platforms", "uiLocales", "localeMap", "metadataDir", "screenshots", "rating", "ageRating", "privacy", "iaps", "previews", "export", "ios", "google", "accessibility", "bridge", "push", "reviewContact", "allowCrossStoreTerms"];
10
12
 
11
13
  // One `vydanne.config.mjs` per app (ESM, like zdymak.config.mjs) — nothing hard-coded. Secrets stay out:
12
14
  // credentials resolve from the environment, a gitignored .env, or ~/.appstoreconnect/config.json (see
@@ -22,23 +24,64 @@ export async function loadConfig(p) {
22
24
  };
23
25
  const creds = resolveCredentials(raw, path.dirname(file));
24
26
  for (const w of creds.warnings) console.warn(`\x1b[33mvydanne: ${w}\x1b[0m`);
27
+ // No `raw` passthrough, deliberately. It was assigned here and read by nothing — an attractive
28
+ // nuisance rather than dead weight: while `config.ios` sat unassigned (the bug check-config.mjs now
29
+ // guards), `config.raw.ios` WAS populated, so "fixing" a caller by reading through `raw` would have
30
+ // entrenched the dropped key instead of exposing it. Everything a command may read is an explicit
31
+ // key below, where the guard can see it.
25
32
  const c = {
26
- raw,
27
33
  credentials: creds,
28
34
  bundleId: need("bundleId"),
29
35
  primaryLocale: need("primaryLocale"),
30
36
  keyId: creds.keyId,
31
37
  issuerId: creds.issuerId,
32
38
  uiLocales: raw.uiLocales || [],
39
+ // App code -> App Store locale, for codes Apple spells differently or does not know yet. Merged over
40
+ // the built-in table rather than replacing it, so an app declares only its exceptions.
41
+ localeMap: raw.localeMap || null,
33
42
  platforms: raw.platforms || ["IOS"],
34
43
  rating: raw.rating || "4+",
44
+ // Content descriptors for a rating above 4+. Null means "the 4+ shorthand", which `age-rating`
45
+ // expands to an all-NONE declaration; anything else must be declared, never guessed.
46
+ ageRating: raw.ageRating || null,
35
47
  privacy: raw.privacy || { collected: ["CRASH_DATA", "PERFORMANCE_DATA"], tracking: false },
36
48
  iaps: raw.iaps || [],
37
49
  metadataDir: raw.metadataDir || "fastlane/metadata",
50
+ // Where each platform's screenshots live. fastlane's supply convention by default — the same
51
+ // relationship `metadataDir` has always had to it, and for the same reason: a convention worth
52
+ // defaulting to is not a reason to be unable to point the tool at your own repo.
53
+ screenshots: raw.screenshots
54
+ ? { IOS: raw.screenshots.IOS || DEFAULT_SCREENSHOT_BASE.IOS, MAC_OS: raw.screenshots.MAC_OS || DEFAULT_SCREENSHOT_BASE.MAC_OS }
55
+ : null,
56
+ // App Review contact overrides. The PII itself still comes from the gitignored
57
+ // `<metadataDir>/review_information/*.txt` — this is only for what isn't a secret.
58
+ reviewContact: raw.reviewContact || null,
59
+ // `push: { skip: [...] }` — steps this app never runs. `--skip` adds to it per invocation; both are
60
+ // reported on every run, because a skipped step must never read as a completed one.
61
+ push: raw.push ? { skip: raw.push.skip || [] } : null,
62
+ // `bridge`: where zdymak wrote, and which of its output directories feed which store slot. Both
63
+ // default to zdymak's own conventions; both must be overridable, because `dir:` overrides in
64
+ // zdymak.config.mjs mean the directory name and the target name are not the same thing.
65
+ bridge: raw.bridge
66
+ ? { out: raw.bridge.out || null, apple: raw.bridge.apple || null, play: raw.bridge.play || null }
67
+ : null,
38
68
  // Terms the cross-store check must not flag for this app (see src/crossStore.mjs).
39
69
  allowCrossStoreTerms: raw.allowCrossStoreTerms || [],
40
70
  previews: raw.previews || null,
41
71
  export: raw.export || { encryption: "standard" },
72
+ // What the app claims to support, for the Accessibility Nutrition Labels. Passed through RAW and
73
+ // deliberately NOT defaulted: `accessibility` validates its own block and must be able to tell
74
+ // "declared nothing" from "declared everything false" — a default here would turn a missing block
75
+ // into a silent set of claims, which is the one thing that command exists to refuse.
76
+ accessibility: raw.accessibility || null,
77
+ // iOS build upload (`prerelease`). Normalised like `google` below so the shape is stable whether or
78
+ // not the block is present.
79
+ ios: raw.ios
80
+ ? {
81
+ ipa: raw.ios.ipa || null, // VYDANNE_IPA overrides, resolved in prerelease.resolveIpa
82
+ testFlightGroup: raw.ios.testFlightGroup || null, // internal groups only; external is refused
83
+ }
84
+ : null,
42
85
  // Google Play. serviceAccountKey resolves from PLAY_JSON_KEY_FILE env first (keep the secret path out
43
86
  // of the committed config). metadataDir follows fastlane supply's convention (fastlane/metadata/android).
44
87
  google: raw.google
@@ -49,10 +92,16 @@ export async function loadConfig(p) {
49
92
  defaultLocale: raw.google.defaultLocale || raw.primaryLocale,
50
93
  aab: raw.google.aab || null,
51
94
  track: raw.google.track || "internal", // testing only — `prerelease` refuses production
52
-
95
+ // Play image type -> local source path. Merged over the defaults so an app overrides only the
96
+ // slots whose layout differs; the default for `icon` points at znachok's output, which is a
97
+ // sensible default for this portfolio and a mystery to anyone else, so it is overridable.
98
+ images: { ...DEFAULT_PLAY_IMAGES, ...(raw.google.images || {}) },
99
+ // Play supports per-language graphics. Uploading only to `defaultLocale` is a choice, not a
100
+ // platform limit — list locales here (or "*" for every local listing folder) to localize them.
101
+ imageLocales: raw.google.imageLocales || null,
53
102
  }
54
103
  : null,
55
104
  };
56
- c.resolvedLocales = resolveLocales(c.uiLocales);
105
+ c.resolvedLocales = resolveLocales(c.uiLocales, c.localeMap);
57
106
  return c;
58
107
  }
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
  }