vydanne 0.10.0 → 0.11.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
@@ -235,7 +235,7 @@ already right. `VYDANNE_FLATTEN=1` reads a flat screenshot folder rather than pe
235
235
  | `diff` | Shows exactly what's different between your files and what's live. Nothing is changed — a safe preview. |
236
236
  | `fill` | Uploads your listing text and screenshots. Handles iPhone, iPad and Mac. Refuses to upload text that names the other mobile platform. |
237
237
  | `previews` | Uploads App Preview videos. |
238
- | `inspect` | Shows the app's current state in the store. Read-only. |
238
+ | `inspect` | Shows the app's current state in the store. Read-only. With `--store google` it also lists every track carrying a release — track, versionCode, version name, status, and the rollout percentage of a staged release. |
239
239
  | `locales` | Lists your languages and Apple's code for each — and warns about any language the App Store doesn't offer. |
240
240
  | `appinfo` | Sets the App Store **category** and the **content-rights** answer — the two app-level facts that block *Add for Review* and belong to no single release. Declared as `categories` (Apple's ids: `GAMES`, `GAMES_PUZZLE` — never display names) and `contentRights: false` for an app that shows no third-party content. Left undeclared, `contentRights` is not written at all, so an answer already given in App Store Connect is never overwritten by a default nobody chose. |
241
241
  | `age-rating` | Sets the age rating. `rating: "4+"` needs nothing else; any higher rating is described feature-by-feature in `ageRating` and Apple computes the band from it. |
package/SKILL.md CHANGED
@@ -297,6 +297,11 @@ than silent. The versionCode is read out of the `.aab` locally and the changelog
297
297
  BEFORE the upload; re-uploading a used code fails loudly instead of silently replacing. DRY by default;
298
298
  `--apply` publishes.
299
299
 
300
+ `inspect --store google` also reports **which build is on which track** — versionCode, version
301
+ name, status, and a staged rollout's percentage — for every track that carries a release. Tracks
302
+ with no release are omitted. That is the question after any upload, and it could not be answered
303
+ from this tool at all before 0.11.
304
+
300
305
  `--store google` routes `inspect` · `diff` · `preflight` · `fill` · `prerelease` to the Play Developer **Edits** API
301
306
  (OAuth2 service account; **scoped to the config's `packageName`** — a shared key can't touch another app).
302
307
  The AAB binary and the (YouTube-URL) promo video stay outside vydanne.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vydanne",
3
- "version": "0.10.0",
3
+ "version": "0.11.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",
@@ -86,6 +86,35 @@ export class PlayClient {
86
86
 
87
87
  getTrack(editId, track) { return this.req("GET", `/edits/${editId}/tracks/${track}`); }
88
88
 
89
+ /**
90
+ * The track NAMES this app has, from inside an edit.
91
+ *
92
+ * Only the names are worth taking from here. The `releases` this returns are the track's DESIRED
93
+ * state — whatever the last edit wrote — which is not what anyone means by "what is on the track".
94
+ * Use [trackReleases] for that; see its note.
95
+ */
96
+ listTracks(editId) { return this.req("GET", `/edits/${editId}/tracks`); }
97
+
98
+ /**
99
+ * What is ACTUALLY on a track: every non-obsolete release, with its lifecycle state.
100
+ *
101
+ * Needs no edit, and it is a different answer from `edits/…/tracks`. That one reports the last
102
+ * write; this reports reality, and the two disagree exactly when it matters most. Measured on
103
+ * Niva, 2026-09-05:
104
+ *
105
+ * edits/…/tracks alpha → [131] "the upload worked"
106
+ * tracks/alpha/… alpha → 1.4 (131) IN_REVIEW
107
+ * + 1.0 (102) PUBLISHED "…and testers still have 1.0"
108
+ *
109
+ * A newly uploaded build does not reach testers until review passes, so the previously published
110
+ * release keeps serving — and the edit-based view cannot see it at all. Reporting only the edit
111
+ * view meant `inspect` said a release was live when nobody had it yet.
112
+ *
113
+ * Shape differs from the edits API too: `releaseName`, `activeArtifacts[].versionCode` and
114
+ * `releaseLifecycleState`, not `name`/`versionCodes`/`status`.
115
+ */
116
+ trackReleases(track) { return this.req("GET", `/tracks/${encodeURIComponent(track)}/releases`); }
117
+
89
118
  /**
90
119
  * Point a track at version codes. `releases` is the FULL desired state of that track — Play replaces
91
120
  * it wholesale, so send one complete release object rather than appending to what is already there.
@@ -1,7 +1,14 @@
1
1
  const IMAGE_TYPES = ["phoneScreenshots", "sevenInchScreenshots", "tenInchScreenshots", "wearScreenshots", "tvScreenshots", "featureGraphic", "icon", "promoGraphic"];
2
2
 
3
- // Read-only snapshot of the Play listing — languages, contact details, image counts. Everything reads
4
- // through one throwaway edit (deleted, never committed), the same as the ASC `inspect`.
3
+ // Read-only snapshot of the Play listing — languages, contact details, image counts, and WHAT IS
4
+ // PUBLISHED WHERE. Everything reads through one throwaway edit (deleted, never committed), the same
5
+ // as the ASC `inspect`.
6
+ //
7
+ // The tracks half was missing until 2026-09-05, and its absence was the difference between "the
8
+ // upload command said it worked" and knowing. `inspect` reported the listing beautifully and could
9
+ // not answer the only question anyone asks after a release — which build is on which track, under
10
+ // what version name — so that had to be worked out by hand against the raw androidpublisher API.
11
+ // A read-only command that cannot show you the state you just changed is half a command.
5
12
  export async function run(config, client) {
6
13
  const g = config.google;
7
14
  const editId = await client.newEdit();
@@ -17,6 +24,68 @@ export async function run(config, client) {
17
24
  if (imgs.length) counts.push(`${t}=${imgs.length}`);
18
25
  }
19
26
  console.log(` images (${lang}): ${counts.join(" ") || "(none)"}`);
27
+
28
+ // What is actually ON each track, which is NOT what the edit says is on it.
29
+ //
30
+ // The edit's own `tracks` gives the desired state — the last thing written — so straight after
31
+ // an upload it happily reports the new build and nothing else. The track's `releases` endpoint
32
+ // reports reality: the build in review AND the older one still serving testers underneath it,
33
+ // because a new build does not reach anyone until review passes. Reporting the edit view alone
34
+ // said a release was live when nobody had it (measured on Niva, 2026-09-05).
35
+ //
36
+ // So the edit is used only to enumerate track NAMES; every state below comes from the
37
+ // non-edit endpoint.
38
+ const LIFECYCLE = {
39
+ RELEASE_LIFECYCLE_STATE_PUBLISHED: "published",
40
+ RELEASE_LIFECYCLE_STATE_IN_REVIEW: "in review",
41
+ RELEASE_LIFECYCLE_STATE_DRAFT: "draft",
42
+ RELEASE_LIFECYCLE_STATE_HALTED: "halted",
43
+ RELEASE_LIFECYCLE_STATE_UNSPECIFIED: "unspecified",
44
+ };
45
+ const edited = (await client.listTracks(editId)).json.tracks || [];
46
+ const rows = [];
47
+ let degraded = null;
48
+ for (const t of edited) {
49
+ const res = await client.trackReleases(t.track);
50
+ // 404 is information, not a failure: the app has never shipped to this track. "Does not
51
+ // exist" and "exists but empty" are different facts and neither earns a row.
52
+ if (res.status === 404) continue;
53
+ if (res.status !== 200) {
54
+ // ANYTHING ELSE MUST NOT BE SWALLOWED. `req` returns a status rather than throwing, so an
55
+ // earlier version of this that caught exceptions caught nothing at all — a 403 produced
56
+ // zero rows and the command printed "nothing published" about an app with three live
57
+ // tracks. The endpoint is quota-limited ("Listing releases quota exceeded"), so this is a
58
+ // state a normal day reaches, not an exotic one. Fall back to the edit's view, which is the
59
+ // last write rather than what is serving, and label every row so it is never mistaken for
60
+ // the real thing.
61
+ degraded = res.json?.error?.message || `HTTP ${res.status}`;
62
+ for (const rel of t.releases || []) {
63
+ const codes = (rel.versionCodes || []).join(",") || "-";
64
+ if (codes === "-") continue;
65
+ rows.push(` ~ ${t.track.padEnd(12)} ${codes.padEnd(8)} ${(rel.name || "-").padEnd(12)} ${rel.status || "?"}`);
66
+ }
67
+ continue;
68
+ }
69
+ for (const rel of res.json.releases || []) {
70
+ const codes = (rel.activeArtifacts || []).map((a) => a.versionCode).join(",") || "-";
71
+ const state = LIFECYCLE[rel.releaseLifecycleState] || rel.releaseLifecycleState || "?";
72
+ const staged = rel.userFraction != null ? ` ${Math.round(rel.userFraction * 100)}% rollout` : "";
73
+ rows.push(` ${t.track.padEnd(12)} ${codes.padEnd(8)} ${(rel.releaseName || "-").padEnd(12)} ${state}${staged}`);
74
+ }
75
+ }
76
+ if (rows.length) {
77
+ console.log(` tracks (${rows.length} release(s)):`);
78
+ console.log(` ${"track".padEnd(12)} ${"code".padEnd(8)} ${"name".padEnd(12)} state`);
79
+ for (const r of rows) console.log(r);
80
+ } else if (degraded) {
81
+ console.log(" tracks: could not be read — " + degraded);
82
+ } else {
83
+ console.log(" tracks: (nothing published — no track carries a release)");
84
+ }
85
+ if (degraded && rows.length) {
86
+ console.log(` ~ rows are the EDIT's view (what was last written), not what is serving.`);
87
+ console.log(` Live state unavailable: ${degraded}`);
88
+ }
20
89
  } finally {
21
90
  await client.deleteEdit(editId);
22
91
  }