vydanne 0.5.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/GETTING_STARTED.md +69 -40
- package/README.md +161 -23
- package/SKILL.md +158 -26
- package/bin/vydanne.mjs +67 -13
- package/package.json +4 -3
- package/src/client.mjs +78 -7
- package/src/commands/accessibility.mjs +13 -0
- package/src/commands/ageRating.mjs +94 -21
- package/src/commands/bridge.mjs +328 -0
- package/src/commands/compliance.mjs +78 -9
- package/src/commands/diff.mjs +63 -19
- package/src/commands/fill.mjs +85 -13
- package/src/commands/inspect.mjs +4 -2
- package/src/commands/preflight.mjs +65 -6
- package/src/commands/prepare.mjs +220 -0
- package/src/commands/prerelease.mjs +25 -5
- package/src/commands/previews.mjs +43 -5
- package/src/commands/privacy.mjs +75 -5
- package/src/commands/push.mjs +118 -0
- package/src/commands/reviewContact.mjs +65 -14
- package/src/config.mjs +55 -4
- package/src/crossStore.mjs +151 -0
- package/src/index.mjs +60 -3
- package/src/locales.mjs +27 -5
- package/src/play/aab.mjs +133 -0
- package/src/play/client.mjs +6 -3
- package/src/play/commands/diff.mjs +40 -0
- package/src/play/commands/fill.mjs +32 -24
- package/src/play/commands/preflight.mjs +5 -0
- package/src/play/commands/prerelease.mjs +96 -22
- package/src/play/images.mjs +67 -0
- package/src/registry.mjs +27 -9
- package/src/screenshots.mjs +112 -0
- package/src/upload.mjs +6 -1
- package/types/index.d.ts +240 -10
- package/vydanne.config.example.mjs +54 -1
package/src/commands/diff.mjs
CHANGED
|
@@ -2,6 +2,8 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { green, yellow, red } from "../util.mjs";
|
|
4
4
|
import { VALID } from "../locales.mjs";
|
|
5
|
+
import { md5 } from "../upload.mjs";
|
|
6
|
+
import { localScreenshots, remoteScreenshots, compareShots, localScreenshotLocales } from "../screenshots.mjs";
|
|
5
7
|
|
|
6
8
|
// [asc attribute, local metadata filename, isLongText]
|
|
7
9
|
const VERSION_FIELDS = [
|
|
@@ -9,8 +11,6 @@ const VERSION_FIELDS = [
|
|
|
9
11
|
["whatsNew", "release_notes", true], ["marketingUrl", "marketing_url", false], ["supportUrl", "support_url", false],
|
|
10
12
|
];
|
|
11
13
|
const INFO_FIELDS = [["name", "name", false], ["subtitle", "subtitle", false]];
|
|
12
|
-
const IOS_DEVICE = { iphone69: "APP_IPHONE_67", iphone65: "APP_IPHONE_65", ipad13: "APP_IPAD_PRO_3GEN_129", watch: "APP_WATCH_ULTRA" };
|
|
13
|
-
const MAC_DEVICE = { macos: "APP_DESKTOP" };
|
|
14
14
|
|
|
15
15
|
const norm = (s) => (s == null ? null : String(s).replace(/\r/g, "").replace(/\n+$/, "").trim());
|
|
16
16
|
const short = (s, n = 24) => { s = String(s).replace(/\n/g, " "); return s.length > n ? s.slice(0, n) + "…" : s; };
|
|
@@ -19,13 +19,19 @@ const short = (s, n = 24) => { s = String(s).replace(/\n/g, " "); return s.lengt
|
|
|
19
19
|
// Connect — i.e. what `fill` / `previews` would change. Reads each localization by id (list is sparse).
|
|
20
20
|
export async function run(config, client) {
|
|
21
21
|
await client.findApp(config.bundleId);
|
|
22
|
-
|
|
22
|
+
// allowLive for the same reason as editVersion below: comparing local name/subtitle against what is
|
|
23
|
+
// on sale is a legitimate read, and on an app with nothing in preparation the live record is the
|
|
24
|
+
// only one to compare against.
|
|
25
|
+
const info = await client.appInfo({ allowLive: true });
|
|
23
26
|
const infoLocs = info ? (await client.get(`/v1/appInfos/${info.id}/appInfoLocalizations?limit=200`)).json.data || [] : [];
|
|
24
27
|
let actionable = 0; // differences `fill`/`previews` would actually change (release_notes on a 1.0 is benign)
|
|
25
28
|
|
|
26
29
|
for (const platform of config.platforms) {
|
|
27
|
-
|
|
28
|
-
|
|
30
|
+
// allowLive: comparing local against the version ON SALE is a legitimate question and often the
|
|
31
|
+
// reason to run this before preparing a release. The state is printed below, so it is never unclear
|
|
32
|
+
// WHICH version the comparison is against — and `fill` no longer targets a live one either way.
|
|
33
|
+
const v = await client.editVersion(platform, { allowLive: true });
|
|
34
|
+
if (!v) { console.log(red(`${platform}: no version at all`)); continue; }
|
|
29
35
|
console.log(`${platform} v${v.attributes.versionString} ${v.attributes.appStoreState}`);
|
|
30
36
|
const verLocs = await client.versionLocalizations(v.id);
|
|
31
37
|
const localDirs = fs.existsSync(config.metadataDir)
|
|
@@ -84,24 +90,62 @@ async function mediaDiff(config, client, platform, verLocs) {
|
|
|
84
90
|
const primary = verLocs.find((l) => l.attributes.locale === config.primaryLocale);
|
|
85
91
|
if (!primary) return 0;
|
|
86
92
|
let diffs = 0;
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
|
|
93
|
+
// Compared by CONTENT, not by count — the machinery lives in src/screenshots.mjs and is shared with
|
|
94
|
+
// `preflight`, so the two commands cannot drift apart in what "in sync" means. The history is there.
|
|
95
|
+
// Every locale with a local set, for the reason preflight now does the same: `bridge` writes one
|
|
96
|
+
// folder per translated locale, and checking only the primary reported "in sync" about a listing
|
|
97
|
+
// whose other nineteen locales were showing last month's art.
|
|
98
|
+
for (const code of [...new Set([config.primaryLocale, ...localScreenshotLocales(platform, config)])]) {
|
|
99
|
+
const loc = verLocs.find((l) => l.attributes.locale === code);
|
|
100
|
+
if (!loc) continue;
|
|
101
|
+
const local = localScreenshots(platform, code, config);
|
|
102
|
+
const remote = await remoteScreenshots(client, loc.id);
|
|
103
|
+
for (const dt of new Set([...Object.keys(local), ...Object.keys(remote)])) {
|
|
104
|
+
const c = compareShots(local[dt] || new Map(), remote[dt] || new Map());
|
|
105
|
+
if (!c) continue;
|
|
106
|
+
const label = ` ${yellow("screenshots")} ${dt.replace("APP_", "")}`;
|
|
107
|
+
if (c.kind === "count") {
|
|
108
|
+
diffs++;
|
|
109
|
+
console.log(`${label}: local ${c.local} / remote ${c.remote} (@${code})`);
|
|
110
|
+
} else if (c.kind === "renamed") {
|
|
111
|
+
diffs++;
|
|
112
|
+
console.log(`${label}: ${c.names.length} not on the store by name (${c.names.slice(0, 3).join(", ")}) (@${code})`);
|
|
113
|
+
} else if (c.kind === "stale") {
|
|
114
|
+
diffs++;
|
|
115
|
+
console.log(`${label}: ${c.names.length} of ${c.of} differ in content (${c.names.slice(0, 3).join(", ")}) (@${code})`);
|
|
116
|
+
} else {
|
|
117
|
+
// Apple did not give a checksum back. Say so rather than reporting a match we did not establish.
|
|
118
|
+
console.log(`${label}: ${c.names.length} present, checksum not reported by Apple — content unverified (@${code})`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
98
121
|
}
|
|
122
|
+
// Previews by content too — the count comparison this replaces had the screenshot bug in miniature:
|
|
123
|
+
// one-local-vs-one-remote reads "in sync" however different the videos are, so a re-rendered App
|
|
124
|
+
// Preview reported nothing to do. `uploadAsset` commits md5(bytes) as sourceFileChecksum for
|
|
125
|
+
// previews exactly as it does for screenshots, so the same comparison is available for free.
|
|
99
126
|
const { json: psets } = await client.get(`/v1/appStoreVersionLocalizations/${primary.id}/appPreviewSets?include=appPreviews&limit=50`);
|
|
127
|
+
const prevById = new Map((psets.included || []).filter((r) => r.type === "appPreviews").map((r) => [r.id, r.attributes || {}]));
|
|
100
128
|
const remotePrev = {};
|
|
101
|
-
for (const s of psets.data || [])
|
|
129
|
+
for (const s of psets.data || []) {
|
|
130
|
+
remotePrev[s.attributes.previewType] = (s.relationships?.appPreviews?.data || [])
|
|
131
|
+
.map((ref) => prevById.get(ref.id)?.sourceFileChecksum ?? null);
|
|
132
|
+
}
|
|
102
133
|
for (const spec of (config.previews || []).filter((s) => s.platform === platform && (s.locales || []).includes(config.primaryLocale))) {
|
|
103
|
-
const
|
|
104
|
-
|
|
134
|
+
const file = path.resolve(spec.file);
|
|
135
|
+
const L = fs.existsSync(file) ? 1 : 0;
|
|
136
|
+
const R = remotePrev[spec.type] || [];
|
|
137
|
+
const label = ` ${yellow("preview")} ${spec.type}`;
|
|
138
|
+
if (L !== R.length) {
|
|
139
|
+
diffs++;
|
|
140
|
+
console.log(`${label}: local ${L} / remote ${R.length} (@${config.primaryLocale})`);
|
|
141
|
+
} else if (L === 1) {
|
|
142
|
+
if (R.every((sum) => sum === null)) {
|
|
143
|
+
console.log(`${label}: present, checksum not reported by Apple — content unverified`);
|
|
144
|
+
} else if (!R.includes(md5(fs.readFileSync(file)))) {
|
|
145
|
+
diffs++;
|
|
146
|
+
console.log(`${label}: differs in content (${path.basename(spec.file)}) (@${config.primaryLocale})`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
105
149
|
}
|
|
106
150
|
return diffs;
|
|
107
151
|
}
|
package/src/commands/fill.mjs
CHANGED
|
@@ -3,12 +3,12 @@ import path from "node:path";
|
|
|
3
3
|
import { green, yellow, red } from "../util.mjs";
|
|
4
4
|
import { VALID } from "../locales.mjs";
|
|
5
5
|
import { uploadAsset } from "../upload.mjs";
|
|
6
|
+
import { reportCrossStore } from "../crossStore.mjs";
|
|
7
|
+
import { deviceMap, screenshotBase, IMAGE_FILE } from "../screenshots.mjs";
|
|
6
8
|
|
|
7
9
|
// Version-localization fields (attr -> metadata filename) and AppInfo fields (name/subtitle, shared).
|
|
8
10
|
const VERSION_TXT = { description: "description", keywords: "keywords", promotionalText: "promotional_text", whatsNew: "release_notes", marketingUrl: "marketing_url", supportUrl: "support_url" };
|
|
9
11
|
const INFO_TXT = { name: "name", subtitle: "subtitle" };
|
|
10
|
-
const IOS_DEVICE = { iphone69: "APP_IPHONE_67", iphone65: "APP_IPHONE_65", ipad13: "APP_IPAD_PRO_3GEN_129", watch: "APP_WATCH_ULTRA" };
|
|
11
|
-
const MAC_DEVICE = { macos: "APP_DESKTOP" };
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* PATCH a localization, surviving attributes Apple refuses to edit RIGHT NOW.
|
|
@@ -50,25 +50,74 @@ export async function run(config, client) {
|
|
|
50
50
|
let ok = true; // a locale Apple refused must fail the command, not just print
|
|
51
51
|
const skipMeta = process.env.VYDANNE_SKIP_METADATA === "1";
|
|
52
52
|
const skipShots = process.env.VYDANNE_SKIP_SCREENSHOTS === "1";
|
|
53
|
+
|
|
54
|
+
// Checked here and not only in preflight, because preflight is something you REMEMBER to run and
|
|
55
|
+
// this is the thing that actually uploads. A cross-store reference costs a review cycle, and it
|
|
56
|
+
// is free to catch one function call earlier. VYDANNE_ALLOW_CROSS_STORE=1 is the deliberate
|
|
57
|
+
// override for the rare listing that genuinely needs the word.
|
|
58
|
+
if (!skipMeta && process.env.VYDANNE_ALLOW_CROSS_STORE !== "1"
|
|
59
|
+
&& !reportCrossStore("apple", config.metadataDir, config.allowCrossStoreTerms)) {
|
|
60
|
+
console.error(red("fill: refusing to upload — fix the listing text, or set VYDANNE_ALLOW_CROSS_STORE=1."));
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
// No allowLive: name/subtitle live on the app-info record, and the live one must never take a PATCH
|
|
64
|
+
// (Apple would refuse each with INVALID_STATE, failing the whole locale — release notes included).
|
|
53
65
|
const info = await client.appInfo();
|
|
66
|
+
// Said out loud, because the `if (info)` guard below otherwise skips name/subtitle for every locale
|
|
67
|
+
// in perfect silence — twenty locales of "written" with two fields quietly missing from each.
|
|
68
|
+
if (!info) console.log(yellow(" no editable app info — name/subtitle will be skipped (the live record is not writable)"));
|
|
54
69
|
const infoLocs = info ? (await client.get(`/v1/appInfos/${info.id}/appInfoLocalizations?limit=200`)).json.data || [] : [];
|
|
55
70
|
|
|
56
71
|
for (const platform of config.platforms) {
|
|
72
|
+
// No allowLive, deliberately: this is the command whose PATCHes would otherwise land on the listing
|
|
73
|
+
// customers are reading. Refusing is the whole point — an unwritten release note is recoverable, a
|
|
74
|
+
// rewritten live listing is not.
|
|
57
75
|
const v = await client.editVersion(platform);
|
|
58
|
-
if (!v) {
|
|
59
|
-
|
|
76
|
+
if (!v) {
|
|
77
|
+
console.error(red(`fill ${platform}: no editable version — refusing to write to the live listing.`));
|
|
78
|
+
console.error(" `vydanne prepare --apply` creates the version to fill, then re-run this.");
|
|
79
|
+
ok = false;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
console.log(green(`fill ${platform} (metadata=${!skipMeta} screenshots=${!skipShots}) -> ${v.attributes.versionString} ${v.attributes.appStoreState}`));
|
|
60
83
|
const verLocs = await client.versionLocalizations(v.id);
|
|
61
84
|
|
|
62
85
|
if (!skipMeta) {
|
|
63
86
|
let failed = 0;
|
|
64
87
|
const dropped = new Set();
|
|
65
|
-
|
|
88
|
+
// Guarded: a metadataDir that doesn't exist used to surface as a raw ENOENT stack from readdirSync
|
|
89
|
+
// — the Play half of this command has always said it in a sentence, and there is no reason the
|
|
90
|
+
// Apple half should be the one that throws.
|
|
91
|
+
if (!fs.existsSync(config.metadataDir)) {
|
|
92
|
+
console.error(red(`fill ${platform}: no metadata directory at ${config.metadataDir}`));
|
|
93
|
+
console.error(" Create it (one folder per App Store locale), or point `metadataDir` at yours.");
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
const all = fs.readdirSync(config.metadataDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
|
|
97
|
+
const dirs = all.filter((d) => VALID.has(d));
|
|
98
|
+
// A folder Apple has no locale for is dropped from the upload — silently, until now. `de` instead
|
|
99
|
+
// of `de-DE` is the exact mistake locales.mjs calls the #1 gotcha, and the old filter made it look
|
|
100
|
+
// like the locale had simply been forgotten. Naming it costs one line and saves a release.
|
|
101
|
+
const strays = all.filter((d) => !VALID.has(d));
|
|
102
|
+
if (strays.length) console.log(yellow(` ${strays.length} folder(s) are not App Store locale codes, skipped: ${strays.join(", ")} (\`vydanne locales\` lists the valid ones)`));
|
|
66
103
|
for (const code of dirs) {
|
|
67
104
|
const folder = path.join(config.metadataDir, code);
|
|
68
105
|
const read = (f) => { const p = path.join(folder, `${f}.txt`); return fs.existsSync(p) ? fs.readFileSync(p, "utf8").replace(/\n+$/, "") : null; };
|
|
69
106
|
// version localization (description/keywords/promo/whatsNew/urls)
|
|
70
107
|
let vl = verLocs.find((l) => l.attributes.locale === code);
|
|
71
|
-
if (!vl) {
|
|
108
|
+
if (!vl) {
|
|
109
|
+
const c = await client.post(`/v1/appStoreVersionLocalizations`, { data: { type: "appStoreVersionLocalizations", attributes: { locale: code }, relationships: { appStoreVersion: { data: { type: "appStoreVersions", id: v.id } } } } });
|
|
110
|
+
vl = c.json.data;
|
|
111
|
+
// A refused POST leaves `vl` undefined, and the PATCH below then reads `.id` off it — a raw
|
|
112
|
+
// TypeError that kills the whole fill on one bad locale. Report the locale Apple refused and
|
|
113
|
+
// carry on with the rest, the way every other per-locale failure here behaves.
|
|
114
|
+
if (!vl) {
|
|
115
|
+
console.error(red(` ✗ ${code}: could not create localization — ${JSON.stringify(c.json?.errors?.[0]?.detail || c.json).slice(0, 160)}`));
|
|
116
|
+
failed++;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
verLocs.push(vl);
|
|
120
|
+
}
|
|
72
121
|
const vattrs = {};
|
|
73
122
|
for (const [k, f] of Object.entries(VERSION_TXT)) { const t = read(f); if (t != null) vattrs[k] = t; }
|
|
74
123
|
if (Object.keys(vattrs).length) {
|
|
@@ -106,19 +155,42 @@ export async function run(config, client) {
|
|
|
106
155
|
}
|
|
107
156
|
|
|
108
157
|
async function uploadScreenshots(config, client, platform, verLocs) {
|
|
109
|
-
const base = platform
|
|
110
|
-
const DEV = platform
|
|
158
|
+
const base = screenshotBase(platform, config);
|
|
159
|
+
const DEV = deviceMap(platform);
|
|
111
160
|
if (!fs.existsSync(base)) return;
|
|
112
|
-
|
|
161
|
+
const dirs = fs.readdirSync(base, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
|
|
162
|
+
const strays = dirs.filter((d) => !VALID.has(d));
|
|
163
|
+
if (strays.length) console.log(yellow(` ${strays.length} screenshot folder(s) are not App Store locale codes, skipped: ${strays.join(", ")}`));
|
|
164
|
+
for (const code of dirs.filter((d) => VALID.has(d))) {
|
|
113
165
|
const loc = verLocs.find((l) => l.attributes.locale === code);
|
|
114
|
-
|
|
115
|
-
|
|
166
|
+
// Said, not skipped: with VYDANNE_SKIP_METADATA=1 (or a screenshots folder for a locale that has no
|
|
167
|
+
// metadata folder) no localization was created above, and a silent `continue` here reports success
|
|
168
|
+
// for a locale whose screenshots never left the disk.
|
|
169
|
+
if (!loc) { console.log(yellow(` ${code}: no App Store localization — screenshots skipped (run fill with metadata to create it)`)); continue; }
|
|
170
|
+
const files = fs.readdirSync(path.join(base, code)).filter((f) => IMAGE_FILE.test(f)).sort();
|
|
116
171
|
const byDev = {};
|
|
117
|
-
|
|
172
|
+
const unknown = [];
|
|
173
|
+
for (const f of files) { const dt = DEV[f.split("_")[0]]; dt ? (byDev[dt] ||= []).push(f) : unknown.push(f); }
|
|
174
|
+
// The prefix before the first underscore selects the device slot; a file with an unknown one used to
|
|
175
|
+
// vanish without a word — the docs even said "if a screenshot doesn't appear, check the name first",
|
|
176
|
+
// which is the tool telling the user to do its job. Name the files instead.
|
|
177
|
+
if (unknown.length) console.log(yellow(` ${code}: ${unknown.length} file(s) with no device prefix, not uploaded: ${unknown.slice(0, 3).join(", ")}${unknown.length > 3 ? ", …" : ""} (known: ${Object.keys(DEV).join(", ")})`));
|
|
118
178
|
const { json: sets } = await client.get(`/v1/appStoreVersionLocalizations/${loc.id}/appScreenshotSets?include=appScreenshots&limit=50`);
|
|
119
179
|
for (const [dt, list] of Object.entries(byDev)) {
|
|
120
180
|
let set = (sets.data || []).find((s) => s.attributes.screenshotDisplayType === dt);
|
|
121
|
-
|
|
181
|
+
// A populated slot used to `continue` unconditionally — "never duplicate", but it also meant a
|
|
182
|
+
// re-render could NEVER reach a listing that already had images, and because the skip happened
|
|
183
|
+
// before the log below, `fill` reported success having uploaded nothing. Same shape as previews
|
|
184
|
+
// now, and the same VYDANNE_REPLACE flag rather than a second one for the other asset kind.
|
|
185
|
+
const existing = set?.relationships?.appScreenshots?.data || [];
|
|
186
|
+
if (existing.length && process.env.VYDANNE_REPLACE !== "1") {
|
|
187
|
+
console.log(yellow(` ${code}/${dt}: already has ${existing.length}, skipping (set VYDANNE_REPLACE=1 to replace)`));
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
for (const s of existing) { // VYDANNE_REPLACE: drop the old shots so the new upload takes their place
|
|
191
|
+
await client.del(`/v1/appScreenshots/${s.id}`);
|
|
192
|
+
console.log(yellow(` ${code}/${dt}: ${client.dryRun ? "would remove" : "removed"} old screenshot ${s.id}`));
|
|
193
|
+
}
|
|
122
194
|
if (!set) { const c = await client.post(`/v1/appScreenshotSets`, { data: { type: "appScreenshotSets", attributes: { screenshotDisplayType: dt }, relationships: { appStoreVersionLocalization: { data: { type: "appStoreVersionLocalizations", id: loc.id } } } } }); set = c.json.data; }
|
|
123
195
|
for (const f of list) await uploadAsset(client, { type: "appScreenshots", setType: "appScreenshotSet", setId: set.id, filePath: path.join(base, code, f) });
|
|
124
196
|
console.log(green(` ${code}/${dt}: ${list.length} screenshots`));
|
package/src/commands/inspect.mjs
CHANGED
|
@@ -3,8 +3,10 @@ export async function run(config, client) {
|
|
|
3
3
|
await client.findApp(config.bundleId);
|
|
4
4
|
console.log(`APP ${client.app.attributes.name} (${config.bundleId}) id=${client.appId} primary=${client.app.attributes.primaryLocale}`);
|
|
5
5
|
for (const platform of config.platforms) {
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
// allowLive: this command's whole job is reporting what is in the store, and on an app with nothing
|
|
7
|
+
// in preparation the live version is the answer — not something to withhold.
|
|
8
|
+
const v = await client.editVersion(platform, { allowLive: true });
|
|
9
|
+
if (!v) { console.log(` ${platform}: no version at all`); continue; }
|
|
8
10
|
const locs = await client.versionLocalizations(v.id);
|
|
9
11
|
console.log(` ${platform}: v${v.attributes.versionString} ${v.attributes.appStoreState} localizations=${locs.length}`);
|
|
10
12
|
const primary = locs.find((l) => l.attributes.locale === config.primaryLocale) || locs[0];
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { green, red, yellow, LIMITS, VERSION_FIELDS } from "../util.mjs";
|
|
2
|
+
import { reportCrossStore } from "../crossStore.mjs";
|
|
3
|
+
import { localScreenshots, remoteScreenshots, compareShots, localScreenshotLocales, unknownScreenshotDirs } from "../screenshots.mjs";
|
|
2
4
|
|
|
3
5
|
// Verify a listing is submission-complete the CORRECT way — each localization read by id (not the sparse
|
|
4
6
|
// list), char limits, primary-locale coverage, per-platform — and warn on the gotchas before ASC does.
|
|
@@ -7,10 +9,33 @@ export async function run(config, client) {
|
|
|
7
9
|
const problems = [], notes = [];
|
|
8
10
|
const res = config.resolvedLocales;
|
|
9
11
|
if (res.unsupported.length) notes.push(`UI locales with no App Store listing (fall back to ${config.primaryLocale}): ${res.unsupported.join(", ")}`);
|
|
12
|
+
if (res.invalid?.length) problems.push(`localeMap points at code(s) Apple does not have: ${res.invalid.join(", ")}`);
|
|
13
|
+
|
|
14
|
+
// name/subtitle live on the APP-INFO record, not the version, which is why they were never checked
|
|
15
|
+
// here — LIMITS has carried a 30-char limit for each since the beginning and nothing read it, while
|
|
16
|
+
// the README listed them among the limits "checked before upload". Read-only, so `allowLive` is
|
|
17
|
+
// right: on an app with nothing in preparation the live record is the only one to measure.
|
|
18
|
+
const info = await client.appInfo({ allowLive: true });
|
|
19
|
+
if (info) {
|
|
20
|
+
const infoLocs = (await client.get(`/v1/appInfos/${info.id}/appInfoLocalizations?limit=200`)).json.data || [];
|
|
21
|
+
for (const il of infoLocs) {
|
|
22
|
+
const a = await client.localization(il.id, "appInfoLocalizations");
|
|
23
|
+
for (const f of ["name", "subtitle"]) {
|
|
24
|
+
const val = (a[f] || "").toString();
|
|
25
|
+
if (!val) { if (f === "name" && il.attributes.locale === config.primaryLocale) problems.push(`${il.attributes.locale}: name EMPTY`); continue; }
|
|
26
|
+
if (val.length > LIMITS[f]) problems.push(`${il.attributes.locale}: ${f} ${val.length}>${LIMITS[f]}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
10
30
|
|
|
11
31
|
for (const platform of config.platforms) {
|
|
32
|
+
// A blocker, and now reachable: this guard existed before but editVersion() handed back the LIVE
|
|
33
|
+
// version instead of null, so preflight validated the shipped listing and called it submittable.
|
|
12
34
|
const v = await client.editVersion(platform);
|
|
13
|
-
if (!v) {
|
|
35
|
+
if (!v) {
|
|
36
|
+
problems.push(`${platform}: no editable version — run \`vydanne prepare --apply\` to create one`);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
14
39
|
console.log(` ${platform}: version ${v.attributes.versionString} (${v.attributes.appStoreState})`);
|
|
15
40
|
const locs = await client.versionLocalizations(v.id);
|
|
16
41
|
const ascLocales = [...new Set([config.primaryLocale, ...Object.values(res.supported)])];
|
|
@@ -26,14 +51,48 @@ export async function run(config, client) {
|
|
|
26
51
|
}
|
|
27
52
|
}
|
|
28
53
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
54
|
+
// EVERY locale with a local screenshot folder, not just the primary one.
|
|
55
|
+
//
|
|
56
|
+
// By CONTENT, not by count — the same comparison `diff` runs (src/screenshots.mjs), so the two
|
|
57
|
+
// cannot disagree about what "in sync" means. The count-only check that replaced called a STALE
|
|
58
|
+
// set green: a full local recapture left three-local-vs-three-remote, preflight said "no blockers",
|
|
59
|
+
// and the submission would have shipped the old art — which is precisely the state Niva sat in with
|
|
60
|
+
// store screenshots still showing a three-tier selector the app no longer had.
|
|
61
|
+
//
|
|
62
|
+
// Restricting that to `primaryLocale` left the same hole open one locale over, and `bridge` widened
|
|
63
|
+
// it: it populates a folder per translated locale, so a de-DE set re-rendered upstream but never
|
|
64
|
+
// re-uploaded passed green while the primary one was checked in full.
|
|
65
|
+
const shotLocales = [...new Set([config.primaryLocale, ...localScreenshotLocales(platform, config)])];
|
|
66
|
+
const strays = unknownScreenshotDirs(platform, config);
|
|
67
|
+
if (strays.length) notes.push(`${platform}: screenshot folder(s) that are not App Store locale codes, never uploaded: ${strays.join(", ")}`);
|
|
68
|
+
|
|
69
|
+
for (const code of shotLocales) {
|
|
70
|
+
const loc = locs.find((l) => l.attributes.locale === code);
|
|
71
|
+
if (!loc) continue; // a missing primary localization is already a blocker above
|
|
72
|
+
const remote = await remoteScreenshots(client, loc.id);
|
|
73
|
+
const count = Object.values(remote).reduce((n, m) => n + m.size, 0);
|
|
74
|
+
// Only the primary listing MUST have screenshots — Apple falls back to it for any locale without.
|
|
75
|
+
if (!count && code === config.primaryLocale) problems.push(`${platform}/${code}: no screenshots`);
|
|
76
|
+
// Freshness is judged only where a local set exists: no local screenshots means there is nothing
|
|
77
|
+
// to compare against, not that the store's are wrong. A display type the store has and local
|
|
78
|
+
// lacks is likewise left alone — `fill` never deletes by omission, so this does not flag it.
|
|
79
|
+
for (const [dt, L] of Object.entries(localScreenshots(platform, code, config))) {
|
|
80
|
+
const c = compareShots(L, remote[dt] || new Map());
|
|
81
|
+
if (!c) continue;
|
|
82
|
+
const slot = dt.replace("APP_", "");
|
|
83
|
+
if (c.kind === "count") problems.push(`${platform}/${code}: screenshots ${slot} local ${c.local} / remote ${c.remote} — the store set is not the local one (\`fill\`, VYDANNE_REPLACE=1 to replace)`);
|
|
84
|
+
else if (c.kind === "renamed") problems.push(`${platform}/${code}: screenshots ${slot} ${c.names.length} local file(s) not on the store by name (\`fill\`, VYDANNE_REPLACE=1 to replace)`);
|
|
85
|
+
else if (c.kind === "stale") problems.push(`${platform}/${code}: screenshots ${slot} STALE — ${c.names.length} of ${c.of} differ in content; green would ship the old art (\`fill\`, VYDANNE_REPLACE=1 to replace)`);
|
|
86
|
+
else notes.push(`${platform}/${code}: screenshots ${slot} present, checksum not reported by Apple — content unverified`);
|
|
87
|
+
}
|
|
34
88
|
}
|
|
35
89
|
}
|
|
36
90
|
|
|
91
|
+
// Local copy that is about to be uploaded, checked before it can earn a rejection.
|
|
92
|
+
if (!reportCrossStore("apple", config.metadataDir, config.allowCrossStoreTerms)) {
|
|
93
|
+
problems.push("listing text references another mobile platform (see above)");
|
|
94
|
+
}
|
|
95
|
+
|
|
37
96
|
console.log();
|
|
38
97
|
if (!problems.length) console.log(green("preflight: no blockers"));
|
|
39
98
|
else { console.log(red(`preflight: ${problems.length} blocker(s)`)); problems.forEach((p) => console.log(` ${red("x")} ${p}`)); }
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { green, yellow, red } from "../util.mjs";
|
|
4
|
+
import { pointVersionAtBuild, LOCKED_STATES } from "./prerelease.mjs";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Create — or reuse — the App Store version you are preparing, and point it at a build.
|
|
8
|
+
*
|
|
9
|
+
* THE GAP THIS CLOSES. Every other Apple command edits *the version being prepared*, which it finds
|
|
10
|
+
* through `client.editVersion()`. On an app whose only version is already on sale there is nothing
|
|
11
|
+
* editable to return, so that call falls back to the live record — and then `fill` aims its
|
|
12
|
+
* description/whatsNew PATCHes at the version customers are looking at, while `prerelease` declines
|
|
13
|
+
* to attach the build it just uploaded ("no editable App Store version — build uploaded, nothing to
|
|
14
|
+
* attach it to"). Neither command can recover on its own, because the thing that is missing is a
|
|
15
|
+
* *version*, and nothing in vydanne ever created one. So preparing a second release always started
|
|
16
|
+
* with a trip to the App Store Connect web UI — exactly the manual step this tool exists to delete.
|
|
17
|
+
*
|
|
18
|
+
* WHAT IT IS NOT. Creating a version is not submitting one. The record this POSTs lands in
|
|
19
|
+
* PREPARE_FOR_SUBMISSION: an editable draft, and the state `fill`, `previews` and `review-contact`
|
|
20
|
+
* all need before they can write anything. `releaseType: MANUAL` then keeps the release itself a
|
|
21
|
+
* human act even after Apple approves it. Add to Review and Submit stay exactly where they were —
|
|
22
|
+
* with you. There is no argument to this command that reaches a reviewer or the public.
|
|
23
|
+
*
|
|
24
|
+
* WHERE THE NUMBER COMES FROM. The newest build Apple holds, read off its preReleaseVersion — which
|
|
25
|
+
* is the archive's own CFBundleShortVersionString. The marketing version therefore travels with the
|
|
26
|
+
* binary, the same way `prerelease` already lets the build number travel with it: Apple assigns
|
|
27
|
+
* nothing and neither do we, so there is no second place to keep the number in step. Preparing a
|
|
28
|
+
* version *before* its build exists is the one case that needs telling, hence VYDANNE_VERSION.
|
|
29
|
+
*
|
|
30
|
+
* RE-RUNNING IS THE POINT. Find-or-create means the command is idempotent: run it before the build
|
|
31
|
+
* exists to get an empty draft, run it again after `prerelease` to attach the binary, run it a third
|
|
32
|
+
* time and it reports that everything already matches. And once the draft exists, `prerelease`
|
|
33
|
+
* attaches to it unaided — `editVersion()` starts finding a real editable version, so the "nothing
|
|
34
|
+
* to attach it to" dead end disappears for every future release too.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** How the CLI is meant to be driven, printed wherever the command can't finish the job itself. */
|
|
38
|
+
const NEXT_STEPS = [
|
|
39
|
+
" next: `vydanne fill --apply` for metadata, then Add to Review + Submit in App Store Connect.",
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/** Every version Apple holds for this platform. Deliberately NOT `editVersion()`, which collapses the
|
|
43
|
+
* list to a single guess — finding a specific versionString is the whole job here. */
|
|
44
|
+
async function versionsFor(client, platform) {
|
|
45
|
+
const { json } = await client.get(
|
|
46
|
+
`/v1/apps/${client.appId}/appStoreVersions?filter[platform]=${platform}&limit=200` +
|
|
47
|
+
`&fields[appStoreVersions]=versionString,appStoreState`,
|
|
48
|
+
);
|
|
49
|
+
return json.data || [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The newest build, together with the marketing version its archive declares. */
|
|
53
|
+
async function newestBuildWithVersion(client) {
|
|
54
|
+
const { json } = await client.get(
|
|
55
|
+
`/v1/builds?filter[app]=${client.appId}&sort=-uploadedDate&limit=1` +
|
|
56
|
+
`&fields[builds]=version,processingState&include=preReleaseVersion` +
|
|
57
|
+
`&fields[preReleaseVersions]=version`,
|
|
58
|
+
);
|
|
59
|
+
const build = json.data?.[0] ?? null;
|
|
60
|
+
const marketing =
|
|
61
|
+
json.included?.find((r) => r.type === "preReleaseVersions")?.attributes?.version ?? null;
|
|
62
|
+
return { build, marketing };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** `<metadataDir>/copyright.txt`, the same supply convention `fill` reads listing text from. */
|
|
66
|
+
function readCopyright(config) {
|
|
67
|
+
if (!config.metadataDir) return null;
|
|
68
|
+
const p = path.join(config.metadataDir, "copyright.txt");
|
|
69
|
+
if (!fs.existsSync(p)) return null;
|
|
70
|
+
return fs.readFileSync(p, "utf8").trim() || null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Is `a` a higher marketing version than `b`?
|
|
75
|
+
*
|
|
76
|
+
* Compared component-wise as NUMBERS, because Apple refuses a versionString that does not exceed the
|
|
77
|
+
* one on sale and a string compare gets the interesting case backwards: "1.10" < "1.9" as text, and
|
|
78
|
+
* 1.10 > 1.9 as a version. Worth a pre-check rather than a 409 the operator has to decode.
|
|
79
|
+
*/
|
|
80
|
+
export function isHigherVersion(a, b) {
|
|
81
|
+
const pa = String(a).split(".").map((n) => Number(n) || 0);
|
|
82
|
+
const pb = String(b).split(".").map((n) => Number(n) || 0);
|
|
83
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
84
|
+
const x = pa[i] || 0;
|
|
85
|
+
const y = pb[i] || 0;
|
|
86
|
+
if (x !== y) return x > y;
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Find the version called `versionString`, or create it. Returns the version record, or null when it
|
|
93
|
+
* exists but Apple has locked it — a state only the operator can leave, by withdrawing in the UI.
|
|
94
|
+
*/
|
|
95
|
+
export async function ensureVersion(client, config, platform, versionString) {
|
|
96
|
+
const existing = (await versionsFor(client, platform)).find(
|
|
97
|
+
(v) => v.attributes.versionString === versionString,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
if (existing) {
|
|
101
|
+
const state = existing.attributes.appStoreState;
|
|
102
|
+
// Reusing prerelease's LOCKED_STATES rather than enumerating the editable states here: one list
|
|
103
|
+
// to keep right, and it is already the list that decides whether a build may be re-pointed.
|
|
104
|
+
if (LOCKED_STATES.has(state)) {
|
|
105
|
+
console.error(red(` version ${versionString} is ${state} — Apple will not let it be edited.`));
|
|
106
|
+
// Two locked cases, two different fixes — telling someone to "withdraw" a version that is ON
|
|
107
|
+
// SALE is advice they cannot take. Shipped once already means the next release needs a HIGHER
|
|
108
|
+
// number, which is the everyday case: the newest build still declares the shipped version
|
|
109
|
+
// because MARKETING_VERSION was never bumped after release.
|
|
110
|
+
if (state === "READY_FOR_SALE" || state === "REPLACED_WITH_NEW_VERSION") {
|
|
111
|
+
console.error(` That version has shipped; the next release needs a higher number. Bump`);
|
|
112
|
+
console.error(` MARKETING_VERSION and re-archive — or name it now: VYDANNE_VERSION=<next> vydanne prepare --apply`);
|
|
113
|
+
} else {
|
|
114
|
+
console.error(" Withdrawing that submission has reviewer-facing consequences, so it stays your");
|
|
115
|
+
console.error(" call: withdraw it in App Store Connect, then re-run this.");
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
console.log(green(` version ${versionString} already exists (${state}) — reusing it`));
|
|
120
|
+
return existing;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const attributes = { platform, versionString, releaseType: "MANUAL" };
|
|
124
|
+
// Set at creation because it is a version-level field: nothing else in vydanne writes `copyright`,
|
|
125
|
+
// and a version submitted without one is a metadata rejection waiting to happen.
|
|
126
|
+
const copyright = readCopyright(config);
|
|
127
|
+
if (copyright) attributes.copyright = copyright;
|
|
128
|
+
|
|
129
|
+
const r = await client.post(`/v1/appStoreVersions`, {
|
|
130
|
+
data: {
|
|
131
|
+
type: "appStoreVersions",
|
|
132
|
+
attributes,
|
|
133
|
+
relationships: { app: { data: { type: "apps", id: client.appId } } },
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
if (r.status >= 300) {
|
|
138
|
+
console.error(red(` could not create version ${versionString} (${r.status})`));
|
|
139
|
+
for (const e of r.json?.errors || []) console.error(` ${e.title}: ${e.detail}`);
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const how = copyright ? ", copyright set" : "";
|
|
144
|
+
// Said in the tense that happened. A dry run reported "created version 1.2" in green immediately
|
|
145
|
+
// after saying it would POST one — two lines that contradict each other, and the green one is the
|
|
146
|
+
// one people remember.
|
|
147
|
+
console.log(client.dryRun
|
|
148
|
+
? yellow(` WOULD create version ${versionString} — PREPARE_FOR_SUBMISSION, releaseType MANUAL${how}`)
|
|
149
|
+
: green(` created version ${versionString} — PREPARE_FOR_SUBMISSION, releaseType MANUAL${how}`));
|
|
150
|
+
return r.json.data;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function run(config, client) {
|
|
154
|
+
await client.findApp(config.bundleId);
|
|
155
|
+
// EVERY declared platform. iOS and macOS are separate App Store versions with separate records, and
|
|
156
|
+
// preparing only `platforms[0]` left the second one with nothing editable — so `fill MAC_OS` refused
|
|
157
|
+
// ("no editable version") on an app whose iOS draft had just been created, and `push` stopped there.
|
|
158
|
+
// `fill`, `previews` and `preflight` have always looped; this is the command that creates what they
|
|
159
|
+
// loop over.
|
|
160
|
+
let ok = true;
|
|
161
|
+
for (const platform of config.platforms) {
|
|
162
|
+
if (!(await prepareOne(config, client, platform))) ok = false;
|
|
163
|
+
}
|
|
164
|
+
if (!ok) return false;
|
|
165
|
+
|
|
166
|
+
console.log("prepare done — the version is editable.");
|
|
167
|
+
for (const line of NEXT_STEPS) console.log(line);
|
|
168
|
+
console.log(yellow(" Submitting for App Store review stays manual, by design."));
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function prepareOne(config, client, platform) {
|
|
173
|
+
console.log(green(`prepare → App Store version (${platform})`));
|
|
174
|
+
|
|
175
|
+
const { build, marketing } = await newestBuildWithVersion(client);
|
|
176
|
+
const target = process.env.VYDANNE_VERSION || marketing;
|
|
177
|
+
|
|
178
|
+
if (!target) {
|
|
179
|
+
console.error(red("prepare: nothing tells me which version to prepare."));
|
|
180
|
+
console.error(" Apple holds no build to read a marketing version from. Either upload one —");
|
|
181
|
+
console.error(" `vydanne prerelease --apply` — or name the version yourself:");
|
|
182
|
+
console.error(" VYDANNE_VERSION=1.2 vydanne prepare --apply");
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const source = process.env.VYDANNE_VERSION ? "VYDANNE_VERSION" : `build ${build?.attributes?.version}`;
|
|
187
|
+
console.log(` preparing ${target} (from ${source})`);
|
|
188
|
+
|
|
189
|
+
// The version on sale is the floor Apple enforces. Checking it here turns an opaque 409 into a
|
|
190
|
+
// sentence, and catches the likelier mistake: forgetting to bump MARKETING_VERSION before archiving.
|
|
191
|
+
const versions = await versionsFor(client, platform);
|
|
192
|
+
const live = versions.find((v) => v.attributes.appStoreState === "READY_FOR_SALE");
|
|
193
|
+
if (live && live.attributes.versionString !== target && !isHigherVersion(target, live.attributes.versionString)) {
|
|
194
|
+
console.error(red(` ${target} does not exceed ${live.attributes.versionString}, which is on sale.`));
|
|
195
|
+
console.error(" Apple only accepts a higher version. Bump MARKETING_VERSION, re-archive, re-upload.");
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
if (live) console.log(` on sale: ${live.attributes.versionString}`);
|
|
199
|
+
|
|
200
|
+
const version = await ensureVersion(client, config, platform, target);
|
|
201
|
+
if (!version) return false;
|
|
202
|
+
|
|
203
|
+
if (!build) {
|
|
204
|
+
console.log(yellow(" no build uploaded yet — the version is ready for one."));
|
|
205
|
+
console.log(" `vydanne prerelease --apply` will upload it and attach it to this version.");
|
|
206
|
+
} else if (marketing && marketing !== target) {
|
|
207
|
+
// The newest build belongs to a DIFFERENT marketing version — almost always because the archive for
|
|
208
|
+
// this one has not been uploaded yet, and VYDANNE_VERSION named the version ahead of its binary.
|
|
209
|
+
// Attaching it would put the previous release's binary behind the new version's number: Apple rejects
|
|
210
|
+
// that at submission, and it is a confusing thing to debug from the reviewer's message.
|
|
211
|
+
console.log(yellow(` build ${build.attributes.version} declares ${marketing}, not ${target} — not attaching it.`));
|
|
212
|
+
console.log(` Archive ${target} and upload it (\`vydanne prerelease --apply\`); this version is waiting.`);
|
|
213
|
+
} else if (build.attributes.processingState === "PROCESSING") {
|
|
214
|
+
console.log(yellow(` build ${build.attributes.version} is still PROCESSING — not attaching it yet.`));
|
|
215
|
+
console.log(" Re-run once Apple finishes, or let `vydanne prerelease --apply` attach it.");
|
|
216
|
+
} else {
|
|
217
|
+
await pointVersionAtBuild(client, config, build, version);
|
|
218
|
+
}
|
|
219
|
+
return true;
|
|
220
|
+
}
|