vydanne 0.4.2 → 0.5.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/README.md CHANGED
@@ -174,7 +174,7 @@ Run vydanne **from your project folder** — it finds everything relative to whe
174
174
  | `locales` | Lists your languages and Apple's code for each — and warns about any language the App Store doesn't offer. |
175
175
  | `age-rating` | Sets the age rating. |
176
176
  | `review-contact` | Fills in the App Review contact details (who Apple calls if there's a problem). |
177
- | `accessibility` | Saves Accessibility Nutrition Labels. Stays a draft until your app is live. |
177
+ | `accessibility` | Saves Accessibility Nutrition Labels from the `accessibility` block in your config. Stays a draft until your app is live. Refuses to run if you have not declared one — see below. |
178
178
  | `privacy` | Prints the privacy answers to paste into Apple's website (Apple's privacy section has no API). |
179
179
  | `iap` | Checks your in-app purchase text fits, and can strip transparency from an image. |
180
180
  | `compliance` | Generates the US encryption self-classification PDF that Apple asks for. |
@@ -196,10 +196,52 @@ VYDANNE_COMMIT=1 npx vydanne fill --store google # actually do it
196
196
  # Windows PowerShell: $env:VYDANNE_COMMIT = "1"; npx vydanne fill --store google
197
197
  ```
198
198
 
199
- ### `prerelease` — the build, to a testing track
199
+ ### `prerelease` — the build, to testers
200
200
 
201
- `fill` writes the *listing*; `prerelease` uploads the **binary** to a **closed testing track** with
202
- release notes, all inside one edit transaction:
201
+ `fill` writes the *listing*; `prerelease` uploads the **binary** to TestFlight on Apple, or to a
202
+ closed testing track on Play. Neither submits anything for review.
203
+
204
+ **Apple — TestFlight**
205
+
206
+ ```sh
207
+ npx vydanne prerelease # validate, upload, wait for processing
208
+ ```
209
+
210
+ ```js
211
+ ios: {
212
+ ipa: "./dist", // a file, or a directory whose NEWEST .ipa is taken
213
+ testFlightGroup: "Internal", // optional; INTERNAL groups only
214
+ }
215
+ ```
216
+
217
+ The App Store Connect REST API has never accepted a binary, so this is the one command that shells
218
+ out — to `xcrun altool`, which ships with Xcode and authenticates from the same
219
+ `~/.appstoreconnect/private_keys` key `vydanne auth` reports. That makes it **macOS-only**, which it
220
+ checks up front. The archive is validated before it is uploaded, so the common refusals (bad
221
+ entitlements, missing icons, a version Apple already holds) cost seconds rather than a full transfer.
222
+
223
+ Build numbers come from the archive's own `CFBundleVersion` — re-uploading one Apple already holds
224
+ fails loudly instead of quietly replacing a binary.
225
+
226
+ It also **points the version you are preparing at the build it just uploaded**, which is the loop
227
+ this command exists for:
228
+
229
+ ```sh
230
+ npx vydanne prerelease # version 1.1 -> build 66, off to testers
231
+ # …find something, fix it, re-archive with a new build number…
232
+ npx vydanne prerelease # version 1.1: build 66 -> build 67
233
+ ```
234
+
235
+ The version→build relationship holds exactly one build, so re-running re-points it and there is
236
+ nothing to clean up. A version that is no longer editable — `IN_REVIEW`, `READY_FOR_SALE` — is left
237
+ alone and said so, because re-pointing it would mean withdrawing that submission, and that is a
238
+ decision with reviewer-facing consequences.
239
+
240
+ **External TestFlight groups are refused.** Distributing to them requires Beta App Review, which is a
241
+ submission by another name; internal groups are the exact parallel of Play's `internal` track, and on
242
+ a paid app they are the testers who install without buying it.
243
+
244
+ **Google Play — a closed track**
203
245
 
204
246
  ```sh
205
247
  npx vydanne prerelease --store google # dry run
@@ -293,3 +335,34 @@ goes through the Google Play Developer **Edits** API.
293
335
  Releasing a new version: [RELEASING.md](RELEASING.md).
294
336
 
295
337
  MIT.
338
+
339
+
340
+ ### Accessibility Nutrition Labels
341
+
342
+ Every other thing vydanne writes is a *fact* about your app. This one is a **claim about its
343
+ behaviour**, made to Apple — so the tool will not guess it for you.
344
+
345
+ ```js
346
+ accessibility: {
347
+ voiceover: true,
348
+ voiceControl: true,
349
+ largerText: true,
350
+ sufficientContrast: true,
351
+ darkInterface: true,
352
+ differentiateWithoutColorAlone: true,
353
+ reducedMotion: true,
354
+ captions: false,
355
+ audioDescriptions: false,
356
+ },
357
+ ```
358
+
359
+ Every feature is stated explicitly. An omission would read as a quiet "no", which is just as
360
+ unverified as a quiet "yes", so a partial block is rejected along with a missing one.
361
+
362
+ Earlier versions applied one hardcoded matrix to every app, which meant an app inherited claims
363
+ nobody had checked against it. At least one shipped app declared Larger Text support while its
364
+ board glyphs scaled twice and grew off the high-contrast disc behind them. If you are upgrading,
365
+ audit before you declare.
366
+
367
+ Apple's platform caveats are still applied automatically: Larger Text does not exist on macOS and
368
+ Voice Control does not exist on watchOS, so those are sent as false whatever you declare.
package/SKILL.md CHANGED
@@ -164,7 +164,17 @@ live) · `privacy` (prints answers for the UI — the API can't reach Apple's ir
164
164
  RGB flatten) · `compliance` (US self-classification PDF) · `diff` (what differs vs live) · `preflight`
165
165
  (completeness gate) · `inspect` · `auth` (what credentials resolved, and from where) · `locales` · `version`.
166
166
 
167
- `prerelease` (**Play only**) uploads an `.aab` to a **closed testing track** with release notes, inside one
167
+ `prerelease` uploads the BUILD. On Apple it validates and uploads the `.ipa` to **TestFlight** via
168
+ `xcrun altool` — the one command that shells out, because the ASC REST API has never carried a binary,
169
+ which also makes it macOS-only. `.ipa` comes from `ios.ipa` / `VYDANNE_IPA` (a directory takes its
170
+ newest), the build number from the archive's own `CFBundleVersion`, and `ios.testFlightGroup` may add
171
+ it to an **internal** group. External groups are REFUSED — they need Beta App Review, a submission by
172
+ another name — and App Store review is never submitted, mirroring the Play side refusing `production`.
173
+ It then points the version being prepared AT that build, so the fix-and-re-upload loop is one command:
174
+ re-running re-points (the relationship holds one build), while a version that is `IN_REVIEW` or
175
+ `READY_FOR_SALE` is left alone, since re-pointing it would mean withdrawing a submission.
176
+
177
+ With `--store google` it uploads an `.aab` to a **closed testing track** with release notes, inside one
168
178
  edit transaction. `production` is REFUSED — not flag-gated — so no argument combination ships to the
169
179
  public; promoting the tested build stays a human's job, mirroring the Apple side never submitting. Track
170
180
  comes from `google.track` / `VYDANNE_TRACK`, default `internal`; the bundle from `google.aab` /
package/bin/vydanne.mjs CHANGED
@@ -62,7 +62,8 @@ try {
62
62
  const spec = COMMANDS[cmd];
63
63
  const { run } = await import(`../src/commands/${spec.mod}.mjs`);
64
64
  const client = spec.client ? new Client({ keyId: cfg.keyId, issuerId: cfg.issuerId }) : null;
65
- const ok = await run(cfg, client);
65
+ // altool authenticates on its own rather than through our JWT, so it needs the raw ids.
66
+ const ok = await run(cfg, client, spec.credentials ? { keyId: cfg.keyId, issuerId: cfg.issuerId } : undefined);
66
67
  if (ok === false) process.exit(1);
67
68
  } else {
68
69
  console.error(usage());
@@ -87,7 +88,8 @@ usage: vydanne <command> [--config vydanne.config.mjs]
87
88
  inspect read-only ASC state
88
89
  diff show what differs between local (metadata/screenshots/previews) and ASC
89
90
  preflight verify submission-completeness (the gotcha checker)
90
- prerelease --store google: upload the .aab to a closed testing track (refuses production)
91
+ prerelease upload the build for testers .ipa to TestFlight (internal groups only),
92
+ or --store google: the .aab to a closed track. Refuses production/review.
91
93
  locales UI -> ASC locale mapping + unsupported
92
94
  auth which credentials resolved, and from where (masked) — run this on a 401
93
95
  credentials: env > .env cascade (.env, .env.<mode>, .env.local, .env.<mode>.local) > user config
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vydanne",
3
- "version": "0.4.2",
3
+ "version": "0.5.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, a diff of local-vs-store, and a preflight verifier that encodes the store gotchas. iOS/macOS via the ASC REST API; Android via the Play Developer Edits API (--store google).",
5
5
  "keywords": [
6
6
  "app-store-connect",
@@ -1,36 +1,146 @@
1
1
  import { green, yellow, red } from "../util.mjs";
2
2
 
3
- const T = true, F = false;
4
- // Honest DoD-backed matrix; Apple caveats: Larger Text N/A on Mac, Voice Control N/A on Watch, no video →
5
- // no captions/AD. DRAFT-safe; VYDANNE_A11Y_PUBLISH=1 publishes, but Apple 409s publish until the app is live.
6
- const MATRIX = {
7
- IPHONE: { supportsVoiceover: T, supportsVoiceControl: T, supportsLargerText: T, supportsSufficientContrast: T, supportsDarkInterface: T, supportsDifferentiateWithoutColorAlone: T, supportsReducedMotion: T, supportsCaptions: F, supportsAudioDescriptions: F },
8
- IPAD: { supportsVoiceover: T, supportsVoiceControl: T, supportsLargerText: T, supportsSufficientContrast: T, supportsDarkInterface: T, supportsDifferentiateWithoutColorAlone: T, supportsReducedMotion: T, supportsCaptions: F, supportsAudioDescriptions: F },
9
- MAC: { supportsVoiceover: T, supportsVoiceControl: T, supportsSufficientContrast: T, supportsDarkInterface: T, supportsDifferentiateWithoutColorAlone: T, supportsReducedMotion: T, supportsCaptions: F, supportsAudioDescriptions: F },
10
- APPLE_WATCH: { supportsVoiceover: T, supportsLargerText: T, supportsSufficientContrast: T, supportsDarkInterface: T, supportsDifferentiateWithoutColorAlone: T, supportsReducedMotion: T, supportsCaptions: F, supportsAudioDescriptions: F },
3
+ /**
4
+ * Accessibility Nutrition Labels.
5
+ *
6
+ * These used to come from a hardcoded matrix that said the same thing for every app the tool was
7
+ * pointed at. That is the wrong shape for this one command. Everything else vydanne writes is a
8
+ * FACT about an app its name, its price, the data it collects but this is a CLAIM about
9
+ * behaviour, made to Apple, and the tool had no way of knowing whether it was true. It asserted
10
+ * VoiceOver, Voice Control and Larger Text support for apps nobody had audited, and at least one
11
+ * of them did not honour Larger Text at all: its board glyphs scaled twice and grew off the
12
+ * high-contrast disc drawn behind them.
13
+ *
14
+ * So the app declares it, feature by feature, in `vydanne.config.mjs`. Silence is not consent — an
15
+ * app with no `accessibility` block gets an error, not a default, because "nobody wrote this down"
16
+ * must never become "supports everything".
17
+ *
18
+ * Apple's platform caveats are still applied here, because they are facts about the platforms
19
+ * rather than about any app: Larger Text does not exist on macOS and Voice Control does not exist
20
+ * on watchOS, so those are forced false regardless of what an app claims.
21
+ */
22
+
23
+ /** Config key -> the ASC attribute it sets. */
24
+ const FEATURES = {
25
+ voiceover: "supportsVoiceover",
26
+ voiceControl: "supportsVoiceControl",
27
+ largerText: "supportsLargerText",
28
+ sufficientContrast: "supportsSufficientContrast",
29
+ darkInterface: "supportsDarkInterface",
30
+ differentiateWithoutColorAlone: "supportsDifferentiateWithoutColorAlone",
31
+ reducedMotion: "supportsReducedMotion",
32
+ captions: "supportsCaptions",
33
+ audioDescriptions: "supportsAudioDescriptions",
34
+ };
35
+
36
+ /** Features Apple does not offer on a device family — never sent, whatever the app declares. */
37
+ const UNAVAILABLE = {
38
+ IPHONE: [],
39
+ IPAD: [],
40
+ MAC: ["largerText"],
41
+ APPLE_WATCH: ["voiceControl"],
11
42
  };
12
43
 
44
+ const EXAMPLE = ` accessibility: {
45
+ voiceover: true,
46
+ voiceControl: true,
47
+ largerText: true,
48
+ sufficientContrast: true,
49
+ darkInterface: true,
50
+ differentiateWithoutColorAlone: true,
51
+ reducedMotion: true,
52
+ captions: false,
53
+ audioDescriptions: false,
54
+ },`;
55
+
56
+ /** Turn the app's declaration into the attributes for one device family. */
57
+ function attributesFor(declared, family) {
58
+ const out = {};
59
+ for (const [key, attribute] of Object.entries(FEATURES)) {
60
+ out[attribute] = UNAVAILABLE[family].includes(key) ? false : declared[key] === true;
61
+ }
62
+ return out;
63
+ }
64
+
65
+ /** Returns a human-readable problem, or null when the declaration is usable. */
66
+ export function validate(config) {
67
+ const declared = config.accessibility;
68
+ if (!declared || typeof declared !== "object") {
69
+ return [
70
+ "accessibility: missing.",
71
+ "This command publishes CLAIMS about your app's behaviour to Apple, so it will not guess.",
72
+ "Declare what you have actually verified:",
73
+ "",
74
+ EXAMPLE,
75
+ ].join("\n");
76
+ }
77
+ const unknown = Object.keys(declared).filter((k) => !(k in FEATURES));
78
+ if (unknown.length) {
79
+ return `accessibility: unknown feature(s) ${unknown.join(", ")}. Known: ${Object.keys(FEATURES).join(", ")}`;
80
+ }
81
+ const missing = Object.keys(FEATURES).filter((k) => typeof declared[k] !== "boolean");
82
+ if (missing.length) {
83
+ return [
84
+ `accessibility: ${missing.join(", ")} must be declared true or false.`,
85
+ "Every feature is stated explicitly — an omission would read as a quiet 'no', which is just",
86
+ "as unverified as a quiet 'yes'.",
87
+ ].join("\n");
88
+ }
89
+ return null;
90
+ }
91
+
13
92
  export async function run(config, client) {
93
+ const problem = validate(config);
94
+ if (problem) {
95
+ console.error(red(problem));
96
+ return false;
97
+ }
98
+ const declared = config.accessibility;
99
+
14
100
  await client.findApp(config.bundleId);
15
101
  const publish = process.env.VYDANNE_A11Y_PUBLISH === "1";
16
102
  const { json } = await client.get(`/v1/apps/${client.appId}/accessibilityDeclarations?limit=50`);
17
103
  const decls = {};
18
104
  for (const d of json.data || []) decls[d.attributes.deviceFamily] = d.id;
105
+
106
+ const claimed = Object.keys(FEATURES).filter((k) => declared[k]);
107
+ console.log(` declaring: ${claimed.length ? claimed.join(", ") : "(nothing)"}`);
108
+
19
109
  let gated = false;
20
- for (const [fam, attributes] of Object.entries(MATRIX)) {
21
- const id = decls[fam];
22
- if (!id) { console.error(yellow(` no ${fam} declaration`)); continue; }
23
- const r = await client.patch(`/v1/accessibilityDeclarations/${id}`, { data: { type: "accessibilityDeclarations", id, attributes } });
24
- if (r.status >= 300) { console.error(red(` ${fam} draft ${r.status}`)); continue; }
110
+ for (const family of Object.keys(UNAVAILABLE)) {
111
+ const id = decls[family];
112
+ if (!id) {
113
+ console.error(yellow(` no ${family} declaration`));
114
+ continue;
115
+ }
116
+ const attributes = attributesFor(declared, family);
117
+ const r = await client.patch(`/v1/accessibilityDeclarations/${id}`, {
118
+ data: { type: "accessibilityDeclarations", id, attributes },
119
+ });
120
+ if (r.status >= 300) {
121
+ console.error(red(` ${family} draft ${r.status}`));
122
+ continue;
123
+ }
25
124
  if (publish) {
26
- const p = await client.patch(`/v1/accessibilityDeclarations/${id}`, { data: { type: "accessibilityDeclarations", id, attributes: { publish: true } } });
27
- if (p.status < 300) console.log(green(` ${fam}: PUBLISHED`));
28
- else if (JSON.stringify(p.json).includes("CANNOT_PUBLISH_APP_MUST_BE_AVAILABLE")) { gated = true; console.log(yellow(` ${fam}: draft saved — publish deferred (app not live yet)`)); }
29
- else console.error(red(` ${fam} publish ${p.status}`));
125
+ const p = await client.patch(`/v1/accessibilityDeclarations/${id}`, {
126
+ data: { type: "accessibilityDeclarations", id, attributes: { publish: true } },
127
+ });
128
+ if (p.status < 300) {
129
+ console.log(green(` ${family}: PUBLISHED`));
130
+ } else if (JSON.stringify(p.json).includes("CANNOT_PUBLISH_APP_MUST_BE_AVAILABLE")) {
131
+ gated = true;
132
+ console.log(yellow(` ${family}: draft saved — publish deferred (app not live yet)`));
133
+ } else {
134
+ console.error(red(` ${family} publish ${p.status}`));
135
+ }
30
136
  } else {
31
- console.log(green(` ${fam}: draft saved`));
137
+ console.log(green(` ${family}: draft saved`));
32
138
  }
33
139
  }
34
- console.log(gated ? yellow("accessibility staged (DRAFT); re-run with VYDANNE_A11Y_PUBLISH=1 once the app is live") : "accessibility done");
140
+ console.log(
141
+ gated
142
+ ? yellow("accessibility staged (DRAFT); re-run with VYDANNE_A11Y_PUBLISH=1 once the app is live")
143
+ : "accessibility done",
144
+ );
35
145
  return true;
36
146
  }
@@ -0,0 +1,259 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ import { green, yellow, red } from "../util.mjs";
6
+
7
+ const run_ = promisify(execFile);
8
+
9
+ /**
10
+ * Upload a build to App Store Connect, for TestFlight.
11
+ *
12
+ * The Play side has had this since the beginning; Apple did not, so the one step that actually
13
+ * moves a binary had to be done by hand for iOS while Android was a command. This closes that,
14
+ * with the same refusals.
15
+ *
16
+ * **The REST API cannot carry a binary.** Everything else vydanne does is ASC REST over a JWT it
17
+ * mints itself, but Apple has never exposed binary upload there — `altool` and Transporter are the
18
+ * only supported paths. So this shells out to `xcrun altool`, which is not a departure from "no
19
+ * fastlane/Ruby": altool ships with Xcode, and you already needed Xcode to produce the .ipa this
20
+ * command uploads. It does mean the command is macOS-only, which is checked up front rather than
21
+ * discovered as a confusing spawn error.
22
+ *
23
+ * WHAT IT REFUSES, mirroring `--store google`:
24
+ * - it does not submit for App Store review. That release is a human's to make, and there is no
25
+ * arrangement of arguments here that reaches the public.
26
+ * - it does not touch EXTERNAL TestFlight groups. External testing needs Beta App Review, which
27
+ * is a submission by another name. Internal groups only — the exact parallel of Play's
28
+ * `internal` track, and for the same practical reason: on a PAID app, internal testers are the
29
+ * ones who install without buying it.
30
+ *
31
+ * Apple assigns nothing: the build number comes from the archive's own CFBundleVersion, so build
32
+ * numbering stays with the build, and re-uploading one Apple already holds fails loudly instead of
33
+ * silently replacing a binary.
34
+ *
35
+ * WHAT IT ALSO DOES: points the version you are preparing AT the build it just uploaded. That is
36
+ * the loop this command exists to serve — ship a build to testers, find something, fix it, upload
37
+ * again — and without it the version keeps whatever build was attached first while TestFlight
38
+ * quietly moves on. Re-running simply re-points: the relationship is a single build, so the newest
39
+ * upload replaces the old one and there is nothing to clean up.
40
+ *
41
+ * It will not re-point a version that is no longer editable (in review, or already released),
42
+ * because Apple would need that version withdrawn first — a decision with reviewer-facing
43
+ * consequences, and therefore the operator's.
44
+ */
45
+
46
+ /** How long to watch processing before handing back. Apple is usually minutes, occasionally not. */
47
+ const PROCESS_TIMEOUT_MS = 15 * 60 * 1000;
48
+ const POLL_MS = 20 * 1000;
49
+
50
+ /** Newest `.ipa` in a directory, or the file itself. Mirrors how `google.aab` is resolved. */
51
+ export function resolveIpa(spec) {
52
+ const target = process.env.VYDANNE_IPA || spec;
53
+ if (!target) return null;
54
+ if (!fs.existsSync(target)) return null;
55
+ if (fs.statSync(target).isFile()) return target.endsWith(".ipa") ? path.resolve(target) : null;
56
+ const ipas = fs
57
+ .readdirSync(target)
58
+ .filter((f) => f.endsWith(".ipa"))
59
+ .map((f) => path.join(target, f))
60
+ .sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
61
+ return ipas.length ? path.resolve(ipas[0]) : null;
62
+ }
63
+
64
+ async function altool(args, credentials) {
65
+ // altool finds the key itself, in the same ~/.appstoreconnect/private_keys location vydanne
66
+ // already resolves for its JWT — so a working `vydanne auth` is a working upload.
67
+ const full = ["altool", ...args, "--apiKey", credentials.keyId, "--apiIssuer", credentials.issuerId];
68
+ return run_("xcrun", full, { maxBuffer: 32 * 1024 * 1024 });
69
+ }
70
+
71
+ /** The newest build Apple has for this app, if any. */
72
+ async function newestBuild(client) {
73
+ const { json } = await client.get(
74
+ `/v1/builds?filter[app]=${client.appId}&sort=-uploadedDate&limit=1` +
75
+ `&fields[builds]=version,processingState,uploadedDate`,
76
+ );
77
+ return json.data?.[0] ?? null;
78
+ }
79
+
80
+ export async function run(config, client, credentials) {
81
+ const ios = config.ios || {};
82
+
83
+ if (process.platform !== "darwin") {
84
+ console.error(red("prerelease: uploading to App Store Connect needs `xcrun altool`, which is macOS-only."));
85
+ console.error(" Apple has never exposed binary upload over the REST API — altool or Transporter only.");
86
+ return false;
87
+ }
88
+
89
+ const ipa = resolveIpa(ios.ipa);
90
+ if (!ipa) {
91
+ console.error(red("prerelease: no .ipa found — set `ios.ipa` in the config, or pass VYDANNE_IPA=<path>."));
92
+ console.error(" It may be a file or a directory; a directory takes its newest .ipa.");
93
+ return false;
94
+ }
95
+
96
+ await client.findApp(config.bundleId);
97
+ const before = await newestBuild(client);
98
+ const size = (fs.statSync(ipa).size / 1e6).toFixed(1);
99
+ console.log(green("prerelease → App Store Connect (TestFlight)"));
100
+ console.log(` archive: ${path.relative(process.cwd(), ipa) || ipa} (${size} MB)`);
101
+
102
+ // Validate before uploading. A rejected upload has already cost the transfer; altool's validation
103
+ // catches the common refusals (entitlements, missing icons, bad version) in seconds.
104
+ try {
105
+ await altool(["--validate-app", "-f", ipa, "-t", "ios"], credentials);
106
+ console.log(green(" validated"));
107
+ } catch (e) {
108
+ console.error(red(" validation failed — Apple would reject this archive:"));
109
+ console.error(indent(e.stdout || e.stderr || e.message));
110
+ return false;
111
+ }
112
+
113
+ try {
114
+ await altool(["--upload-app", "-f", ipa, "-t", "ios"], credentials);
115
+ console.log(green(" uploaded"));
116
+ } catch (e) {
117
+ const text = `${e.stdout || ""}${e.stderr || ""}` || e.message;
118
+ // The one refusal worth naming: re-uploading a build number Apple already holds. It is the
119
+ // right answer for an accident and a dead end for a re-run, so say which it is.
120
+ if (/already exists|redundant binary|previously uploaded/i.test(text)) {
121
+ console.error(red(" Apple already holds this build number."));
122
+ console.error(" Bump CFBundleVersion (Scripts/build-number.sh in the games) and re-archive.");
123
+ } else {
124
+ console.error(red(" upload failed:"));
125
+ console.error(indent(text));
126
+ }
127
+ return false;
128
+ }
129
+
130
+ const build = await waitForProcessing(client, before);
131
+ if (!build) {
132
+ console.log(yellow(" build not visible yet — Apple is still ingesting it."));
133
+ console.log(" It will appear in TestFlight shortly; nothing further is needed here.");
134
+ return true;
135
+ }
136
+ console.log(green(` build ${build.attributes.version}: ${build.attributes.processingState}`));
137
+
138
+ if (build.attributes.processingState === "FAILED") {
139
+ console.error(red(" Apple failed to process this build — check the email it sent for the reason."));
140
+ return false;
141
+ }
142
+
143
+ if (ios.testFlightGroup) {
144
+ await assignToInternalGroup(client, build, ios.testFlightGroup);
145
+ }
146
+
147
+ await pointVersionAtBuild(client, config, build);
148
+
149
+ console.log("prerelease done — the build is in TestFlight.");
150
+ console.log(yellow(" Submitting for App Store review stays manual, by design."));
151
+ return true;
152
+ }
153
+
154
+ async function waitForProcessing(client, before) {
155
+ const deadline = Date.now() + PROCESS_TIMEOUT_MS;
156
+ let announced = false;
157
+ while (Date.now() < deadline) {
158
+ const build = await newestBuild(client);
159
+ // "New" means a different build than the one that was newest before the upload. Comparing ids
160
+ // rather than timestamps avoids any assumption about clock skew between here and Apple.
161
+ if (build && build.id !== before?.id) {
162
+ if (build.attributes.processingState !== "PROCESSING") return build;
163
+ if (!announced) {
164
+ console.log(" processing…");
165
+ announced = true;
166
+ }
167
+ }
168
+ await new Promise((r) => setTimeout(r, POLL_MS));
169
+ }
170
+ return null;
171
+ }
172
+
173
+ /**
174
+ * Add the build to an INTERNAL TestFlight group.
175
+ *
176
+ * External groups are refused: distributing to them requires Beta App Review, which is a
177
+ * submission, and this tool does not submit.
178
+ */
179
+ async function assignToInternalGroup(client, build, groupName) {
180
+ const { json } = await client.get(
181
+ `/v1/apps/${client.appId}/betaGroups?limit=200&fields[betaGroups]=name,isInternalGroup`,
182
+ );
183
+ const groups = json.data || [];
184
+ const group = groups.find((g) => g.attributes.name === groupName);
185
+ if (!group) {
186
+ const names = groups.map((g) => `${g.attributes.name}${g.attributes.isInternalGroup ? "" : " (external)"}`);
187
+ console.error(yellow(` no TestFlight group named "${groupName}" — have: ${names.join(", ") || "none"}`));
188
+ return;
189
+ }
190
+ if (!group.attributes.isInternalGroup) {
191
+ console.error(red(` "${groupName}" is an EXTERNAL group — refusing.`));
192
+ console.error(" External testing needs Beta App Review, which is a submission by another name.");
193
+ return;
194
+ }
195
+ const r = await client.post(`/v1/betaGroups/${group.id}/relationships/builds`, {
196
+ data: [{ type: "builds", id: build.id }],
197
+ });
198
+ if (r.status < 300) console.log(green(` added to internal group "${groupName}"`));
199
+ else console.error(yellow(` could not add to "${groupName}" (${r.status})`));
200
+ }
201
+
202
+ /** Versions Apple will not let us re-point without the operator withdrawing them first. */
203
+ const LOCKED_STATES = new Set([
204
+ "WAITING_FOR_REVIEW",
205
+ "IN_REVIEW",
206
+ "PENDING_DEVELOPER_RELEASE",
207
+ "PENDING_APPLE_RELEASE",
208
+ "READY_FOR_SALE",
209
+ "REPLACED_WITH_NEW_VERSION",
210
+ ]);
211
+
212
+ /**
213
+ * Attach [build] to the version being prepared, replacing whatever was there.
214
+ *
215
+ * This is what makes "upload a fix and try again" one command rather than a trip to the web UI:
216
+ * the version → build relationship holds exactly one build, so a PATCH re-points it.
217
+ */
218
+ export async function pointVersionAtBuild(client, config, build) {
219
+ const platform = (config.platforms && config.platforms[0]) || "IOS";
220
+ const version = await client.editVersion(platform);
221
+ if (!version) {
222
+ console.log(yellow(" no editable App Store version — build uploaded, nothing to attach it to."));
223
+ return;
224
+ }
225
+ const state = version.attributes.appStoreState;
226
+ const versionString = version.attributes.versionString;
227
+ if (LOCKED_STATES.has(state)) {
228
+ console.log(yellow(` version ${versionString} is ${state} — leaving its build alone.`));
229
+ console.log(" Re-pointing it would mean withdrawing that submission, which is your call.");
230
+ return;
231
+ }
232
+
233
+ // What it currently points at, so a no-op re-run says so rather than looking like a change.
234
+ const { json: current } = await client.get(
235
+ `/v1/appStoreVersions/${version.id}/build?fields[builds]=version`,
236
+ );
237
+ const was = current.data?.attributes?.version ?? current.data?.id ?? null;
238
+ if (current.data?.id === build.id) {
239
+ console.log(green(` version ${versionString} already points at build ${build.attributes.version}`));
240
+ return;
241
+ }
242
+
243
+ const r = await client.patch(`/v1/appStoreVersions/${version.id}/relationships/build`, {
244
+ data: { type: "builds", id: build.id },
245
+ });
246
+ if (r.status < 300) {
247
+ const from = was ? `build ${was} -> ` : "";
248
+ console.log(green(` version ${versionString}: ${from}build ${build.attributes.version}`));
249
+ } else {
250
+ console.error(yellow(` could not attach the build to version ${versionString} (${r.status})`));
251
+ }
252
+ }
253
+
254
+ const indent = (text) =>
255
+ String(text)
256
+ .trim()
257
+ .split("\n")
258
+ .map((l) => ` ${l}`)
259
+ .join("\n");
package/src/config.mjs CHANGED
@@ -6,7 +6,7 @@ import { resolveCredentials } from "./credentials.mjs";
6
6
 
7
7
  // The public config surface — the drift guards assert each key is documented (README/SKILL) and typed
8
8
  // (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", "google"];
9
+ export const CONFIG_KEYS = ["bundleId", "primaryLocale", "asc", "platforms", "uiLocales", "metadataDir", "rating", "privacy", "iaps", "previews", "export", "ios", "google", "accessibility"];
10
10
 
11
11
  // One `vydanne.config.mjs` per app (ESM, like zdymak.config.mjs) — nothing hard-coded. Secrets stay out:
12
12
  // credentials resolve from the environment, a gitignored .env, or ~/.appstoreconnect/config.json (see
package/src/registry.mjs CHANGED
@@ -14,6 +14,9 @@ export const COMMANDS = {
14
14
  inspect: { mod: "inspect", client: true },
15
15
  diff: { mod: "diff", client: true },
16
16
  preflight: { mod: "preflight", client: true },
17
+ // Uploads the .ipa to TestFlight. Needs the credentials as well as the client: the REST API
18
+ // cannot carry a binary, so this one shells out to `xcrun altool`, which authenticates itself.
19
+ prerelease: { mod: "prerelease", client: true, credentials: true },
17
20
  };
18
21
 
19
22
  // Commands available for `--store google` (Google Play). Same names as the Apple ones, different backend
@@ -27,4 +30,4 @@ export const PLAY_COMMANDS = {
27
30
  };
28
31
 
29
32
  // 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"];
33
+ export const COMMAND_NAMES = [...Object.keys(COMMANDS), "auth", "locales", "version"];
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,6 +133,10 @@ 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;
103
142
  }