vydanne 0.2.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 +434 -0
- package/LICENSE +21 -0
- package/README.md +212 -0
- package/SKILL.md +174 -0
- package/bin/vydanne.mjs +66 -0
- package/package.json +66 -0
- package/src/client.mjs +65 -0
- package/src/commands/accessibility.mjs +36 -0
- package/src/commands/ageRating.mjs +33 -0
- package/src/commands/compliance.mjs +45 -0
- package/src/commands/diff.mjs +107 -0
- package/src/commands/fill.mjs +84 -0
- package/src/commands/iap.mjs +31 -0
- package/src/commands/inspect.mjs +20 -0
- package/src/commands/preflight.mjs +42 -0
- package/src/commands/previews.mjs +51 -0
- package/src/commands/privacy.mjs +29 -0
- package/src/commands/reviewContact.mjs +27 -0
- package/src/config.mjs +48 -0
- package/src/index.mjs +6 -0
- package/src/jwt.mjs +17 -0
- package/src/locales.mjs +26 -0
- package/src/play/auth.mjs +29 -0
- package/src/play/client.mjs +68 -0
- package/src/play/commands/diff.mjs +47 -0
- package/src/play/commands/fill.mjs +69 -0
- package/src/play/commands/inspect.mjs +24 -0
- package/src/play/commands/preflight.mjs +37 -0
- package/src/registry.mjs +29 -0
- package/src/upload.mjs +47 -0
- package/src/util.mjs +9 -0
- package/types/index.d.ts +119 -0
- package/vydanne.config.example.mjs +56 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { green, yellow, red } from "../util.mjs";
|
|
4
|
+
import { VALID } from "../locales.mjs";
|
|
5
|
+
import { uploadAsset } from "../upload.mjs";
|
|
6
|
+
|
|
7
|
+
// Version-localization fields (attr -> metadata filename) and AppInfo fields (name/subtitle, shared).
|
|
8
|
+
const VERSION_TXT = { description: "description", keywords: "keywords", promotionalText: "promotional_text", whatsNew: "release_notes", marketingUrl: "marketing_url", supportUrl: "support_url" };
|
|
9
|
+
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
|
+
|
|
13
|
+
// Push metadata (native PATCH — works at any editable state, incl. READY_FOR_REVIEW, unlike deliver) +
|
|
14
|
+
// screenshots (native chunked upload; skips sets that already have shots so it never duplicates).
|
|
15
|
+
// iOS and macOS are separate platforms. Toggles: VYDANNE_SKIP_METADATA / VYDANNE_SKIP_SCREENSHOTS.
|
|
16
|
+
export async function run(config, client) {
|
|
17
|
+
await client.findApp(config.bundleId);
|
|
18
|
+
const skipMeta = process.env.VYDANNE_SKIP_METADATA === "1";
|
|
19
|
+
const skipShots = process.env.VYDANNE_SKIP_SCREENSHOTS === "1";
|
|
20
|
+
const info = await client.appInfo();
|
|
21
|
+
const infoLocs = info ? (await client.get(`/v1/appInfos/${info.id}/appInfoLocalizations?limit=200`)).json.data || [] : [];
|
|
22
|
+
|
|
23
|
+
for (const platform of config.platforms) {
|
|
24
|
+
const v = await client.editVersion(platform);
|
|
25
|
+
if (!v) { console.error(red(`fill ${platform}: no editable version`)); continue; }
|
|
26
|
+
console.log(green(`fill ${platform} (metadata=${!skipMeta} screenshots=${!skipShots})...`));
|
|
27
|
+
const verLocs = await client.versionLocalizations(v.id);
|
|
28
|
+
|
|
29
|
+
if (!skipMeta) {
|
|
30
|
+
const dirs = fs.readdirSync(config.metadataDir, { withFileTypes: true }).filter((d) => d.isDirectory() && VALID.has(d.name)).map((d) => d.name);
|
|
31
|
+
for (const code of dirs) {
|
|
32
|
+
const folder = path.join(config.metadataDir, code);
|
|
33
|
+
const read = (f) => { const p = path.join(folder, `${f}.txt`); return fs.existsSync(p) ? fs.readFileSync(p, "utf8").replace(/\n+$/, "") : null; };
|
|
34
|
+
// version localization (description/keywords/promo/whatsNew/urls)
|
|
35
|
+
let vl = verLocs.find((l) => l.attributes.locale === code);
|
|
36
|
+
if (!vl) { const c = await client.post(`/v1/appStoreVersionLocalizations`, { data: { type: "appStoreVersionLocalizations", attributes: { locale: code }, relationships: { appStoreVersion: { data: { type: "appStoreVersions", id: v.id } } } } }); vl = c.json.data; verLocs.push(vl); }
|
|
37
|
+
const vattrs = {};
|
|
38
|
+
for (const [k, f] of Object.entries(VERSION_TXT)) { const t = read(f); if (t != null) vattrs[k] = t; }
|
|
39
|
+
if (Object.keys(vattrs).length) await client.patch(`/v1/appStoreVersionLocalizations/${vl.id}`, { data: { type: "appStoreVersionLocalizations", id: vl.id, attributes: vattrs } });
|
|
40
|
+
// app-info localization (name/subtitle — shared across platforms). Apple REQUIRES `name` when
|
|
41
|
+
// CREATING a localization (409 ATTRIBUTE.REQUIRED otherwise), so build the attrs first and send
|
|
42
|
+
// them in the POST; only PATCH when the localization already exists.
|
|
43
|
+
if (info) {
|
|
44
|
+
const iattrs = {};
|
|
45
|
+
for (const [k, f] of Object.entries(INFO_TXT)) { const t = read(f); if (t != null) iattrs[k] = t; }
|
|
46
|
+
let il = infoLocs.find((l) => l.attributes.locale === code);
|
|
47
|
+
if (!il) {
|
|
48
|
+
const c = await client.post(`/v1/appInfoLocalizations`, { data: { type: "appInfoLocalizations", attributes: { locale: code, ...iattrs }, relationships: { appInfo: { data: { type: "appInfos", id: info.id } } } } });
|
|
49
|
+
il = c.json.data;
|
|
50
|
+
if (il) infoLocs.push(il);
|
|
51
|
+
else console.error(` appInfo ${code}: create failed — ${JSON.stringify(c.json?.errors?.[0]?.detail || c.json)}`);
|
|
52
|
+
} else if (Object.keys(iattrs).length) {
|
|
53
|
+
await client.patch(`/v1/appInfoLocalizations/${il.id}`, { data: { type: "appInfoLocalizations", id: il.id, attributes: iattrs } });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
console.log(green(` metadata: ${dirs.length} locales`));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!skipShots) await uploadScreenshots(config, client, platform, verLocs);
|
|
61
|
+
}
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function uploadScreenshots(config, client, platform, verLocs) {
|
|
66
|
+
const base = platform === "MAC_OS" ? "fastlane/screenshots-macos" : "fastlane/screenshots";
|
|
67
|
+
const DEV = platform === "MAC_OS" ? MAC_DEVICE : IOS_DEVICE;
|
|
68
|
+
if (!fs.existsSync(base)) return;
|
|
69
|
+
for (const code of fs.readdirSync(base).filter((d) => VALID.has(d))) {
|
|
70
|
+
const loc = verLocs.find((l) => l.attributes.locale === code);
|
|
71
|
+
if (!loc) continue;
|
|
72
|
+
const files = fs.readdirSync(path.join(base, code)).filter((f) => f.endsWith(".png")).sort();
|
|
73
|
+
const byDev = {};
|
|
74
|
+
for (const f of files) { const dt = DEV[f.split("_")[0]]; if (dt) (byDev[dt] ||= []).push(f); }
|
|
75
|
+
const { json: sets } = await client.get(`/v1/appStoreVersionLocalizations/${loc.id}/appScreenshotSets?include=appScreenshots&limit=50`);
|
|
76
|
+
for (const [dt, list] of Object.entries(byDev)) {
|
|
77
|
+
let set = (sets.data || []).find((s) => s.attributes.screenshotDisplayType === dt);
|
|
78
|
+
if (set && (set.relationships?.appScreenshots?.data || []).length) continue; // never duplicate
|
|
79
|
+
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; }
|
|
80
|
+
for (const f of list) await uploadAsset(client, { type: "appScreenshots", setType: "appScreenshotSet", setId: set.id, filePath: path.join(base, code, f) });
|
|
81
|
+
console.log(green(` ${code}/${dt}: ${list.length} screenshots`));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import sharp from "sharp";
|
|
2
|
+
import { green, yellow, red } from "../util.mjs";
|
|
3
|
+
|
|
4
|
+
// Validate IAP fields (Display Name <=30, Description <=45) + remind of the two image slots. Optional:
|
|
5
|
+
// VYDANNE_FLATTEN=<png> flattens a screenshot to RGB (sim captures are RGBA → ASC rejects them).
|
|
6
|
+
export async function run(config) {
|
|
7
|
+
if (process.env.VYDANNE_FLATTEN) return flatten(process.env.VYDANNE_FLATTEN);
|
|
8
|
+
const iaps = config.iaps || [];
|
|
9
|
+
if (!iaps.length) { console.log(yellow("no iaps in config")); return true; }
|
|
10
|
+
let ok = true;
|
|
11
|
+
for (const i of iaps) {
|
|
12
|
+
const dn = (i.displayName || "").length, de = (i.description || "").length;
|
|
13
|
+
const bad = [];
|
|
14
|
+
if (dn > 30) bad.push(`display_name ${dn}>30`);
|
|
15
|
+
if (de > 45) bad.push(`description ${de}>45`);
|
|
16
|
+
if (bad.length) ok = false;
|
|
17
|
+
console.log(`IAP ${i.productId} (${i.type}) ${i.price}`);
|
|
18
|
+
console.log(` Display Name (<=30): ${dn} ${bad.length ? red(bad.join(", ")) : green("ok")}`);
|
|
19
|
+
console.log(` Description (<=45): ${de}`);
|
|
20
|
+
console.log(` Review note: ${(i.reviewNote || "").slice(0, 120)}`);
|
|
21
|
+
}
|
|
22
|
+
console.log(yellow("Two image slots: Review Screenshot (tall, required, RGB) + Promotional Image (1024x1024, optional — from zdymak)."));
|
|
23
|
+
return ok;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function flatten(input) {
|
|
27
|
+
const out = input.replace(/(\.\w+)?$/, "-iap.png");
|
|
28
|
+
await sharp(input).flatten({ background: "#ffffff" }).removeAlpha().png().toFile(out);
|
|
29
|
+
console.log(green(`flattened -> ${out} (RGB, no alpha)`));
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Read-only snapshot of what's in App Store Connect now, per platform.
|
|
2
|
+
export async function run(config, client) {
|
|
3
|
+
await client.findApp(config.bundleId);
|
|
4
|
+
console.log(`APP ${client.app.attributes.name} (${config.bundleId}) id=${client.appId} primary=${client.app.attributes.primaryLocale}`);
|
|
5
|
+
for (const platform of config.platforms) {
|
|
6
|
+
const v = await client.editVersion(platform);
|
|
7
|
+
if (!v) { console.log(` ${platform}: no editable version`); continue; }
|
|
8
|
+
const locs = await client.versionLocalizations(v.id);
|
|
9
|
+
console.log(` ${platform}: v${v.attributes.versionString} ${v.attributes.appStoreState} localizations=${locs.length}`);
|
|
10
|
+
const primary = locs.find((l) => l.attributes.locale === config.primaryLocale) || locs[0];
|
|
11
|
+
if (!primary) continue;
|
|
12
|
+
const ss = await client.get(`/v1/appStoreVersionLocalizations/${primary.id}/appScreenshotSets?include=appScreenshots&limit=50`);
|
|
13
|
+
const sets = (ss.json.data || []).map((s) => `${s.attributes.screenshotDisplayType.replace("APP_", "")}=${(s.relationships?.appScreenshots?.data || []).length}`);
|
|
14
|
+
if (sets.length) console.log(` screenshots (${primary.attributes.locale}): ${sets.join(" ")}`);
|
|
15
|
+
const pp = await client.get(`/v1/appStoreVersionLocalizations/${primary.id}/appPreviewSets?include=appPreviews&limit=50`);
|
|
16
|
+
const psets = (pp.json.data || []).map((s) => `${s.attributes.previewType}=${(s.relationships?.appPreviews?.data || []).length}`).filter((x) => !x.endsWith("=0"));
|
|
17
|
+
if (psets.length) console.log(` previews (${primary.attributes.locale}): ${psets.join(" ")}`);
|
|
18
|
+
}
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { green, red, yellow, LIMITS, VERSION_FIELDS } from "../util.mjs";
|
|
2
|
+
|
|
3
|
+
// Verify a listing is submission-complete the CORRECT way — each localization read by id (not the sparse
|
|
4
|
+
// list), char limits, primary-locale coverage, per-platform — and warn on the gotchas before ASC does.
|
|
5
|
+
export async function run(config, client) {
|
|
6
|
+
await client.findApp(config.bundleId);
|
|
7
|
+
const problems = [], notes = [];
|
|
8
|
+
const res = config.resolvedLocales;
|
|
9
|
+
if (res.unsupported.length) notes.push(`UI locales with no App Store listing (fall back to ${config.primaryLocale}): ${res.unsupported.join(", ")}`);
|
|
10
|
+
|
|
11
|
+
for (const platform of config.platforms) {
|
|
12
|
+
const v = await client.editVersion(platform);
|
|
13
|
+
if (!v) { problems.push(`${platform}: no editable version`); continue; }
|
|
14
|
+
console.log(` ${platform}: version ${v.attributes.versionString} (${v.attributes.appStoreState})`);
|
|
15
|
+
const locs = await client.versionLocalizations(v.id);
|
|
16
|
+
const ascLocales = [...new Set([config.primaryLocale, ...Object.values(res.supported)])];
|
|
17
|
+
|
|
18
|
+
for (const code of ascLocales) {
|
|
19
|
+
const loc = locs.find((l) => l.attributes.locale === code);
|
|
20
|
+
if (!loc) { if (code === config.primaryLocale) problems.push(`${platform}/${code}: localization missing`); continue; }
|
|
21
|
+
const a = await client.localization(loc.id);
|
|
22
|
+
for (const [f, key] of Object.entries(VERSION_FIELDS)) {
|
|
23
|
+
const val = (a[key] || "").toString();
|
|
24
|
+
if (!val) (code === config.primaryLocale ? problems : notes).push(`${platform}/${code}: ${f} EMPTY`);
|
|
25
|
+
else if (LIMITS[f] && val.length > LIMITS[f]) problems.push(`${platform}/${code}: ${f} ${val.length}>${LIMITS[f]}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const primary = locs.find((l) => l.attributes.locale === config.primaryLocale);
|
|
30
|
+
if (primary) {
|
|
31
|
+
const { json } = await client.get(`/v1/appStoreVersionLocalizations/${primary.id}/appScreenshotSets?include=appScreenshots&limit=50`);
|
|
32
|
+
const count = (json.data || []).reduce((n, s) => n + (s.relationships?.appScreenshots?.data || []).length, 0);
|
|
33
|
+
if (!count) problems.push(`${platform}/${config.primaryLocale}: no screenshots`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
console.log();
|
|
38
|
+
if (!problems.length) console.log(green("preflight: no blockers"));
|
|
39
|
+
else { console.log(red(`preflight: ${problems.length} blocker(s)`)); problems.forEach((p) => console.log(` ${red("x")} ${p}`)); }
|
|
40
|
+
notes.forEach((n) => console.log(` ${yellow("!")} ${n}`));
|
|
41
|
+
return problems.length === 0;
|
|
42
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { green, yellow, red } from "../util.mjs";
|
|
4
|
+
import { uploadAsset, setPreviewPoster } from "../upload.mjs";
|
|
5
|
+
|
|
6
|
+
// Upload App Preview videos natively (reserve → chunked PUT → commit → poll processing → poster frame).
|
|
7
|
+
// config.previews: [{ platform, type, file, poster, locales }]. Skips locales that already have a preview.
|
|
8
|
+
export async function run(config, client) {
|
|
9
|
+
await client.findApp(config.bundleId);
|
|
10
|
+
const specs = config.previews || defaultSpecs(config);
|
|
11
|
+
for (const s of specs) {
|
|
12
|
+
const v = await client.editVersion(s.platform);
|
|
13
|
+
if (!v) { console.error(red(` ${s.platform}: no version`)); continue; }
|
|
14
|
+
const locs = await client.versionLocalizations(v.id);
|
|
15
|
+
for (const code of s.locales || [config.primaryLocale]) {
|
|
16
|
+
const loc = locs.find((l) => l.attributes.locale === code);
|
|
17
|
+
if (!loc) continue;
|
|
18
|
+
try {
|
|
19
|
+
const { json: sets } = await client.get(`/v1/appStoreVersionLocalizations/${loc.id}/appPreviewSets?include=appPreviews&limit=50`);
|
|
20
|
+
let set = (sets.data || []).find((x) => x.attributes.previewType === s.type);
|
|
21
|
+
const existing = set?.relationships?.appPreviews?.data || [];
|
|
22
|
+
if (existing.length && process.env.VYDANNE_REPLACE !== "1") {
|
|
23
|
+
console.log(yellow(` ${s.platform}/${code}/${s.type}: already has a preview, skipping (set VYDANNE_REPLACE=1 to replace it)`)); continue;
|
|
24
|
+
}
|
|
25
|
+
for (const p of existing) { // VYDANNE_REPLACE: drop the old preview so the new upload takes its place
|
|
26
|
+
await client.del(`/v1/appPreviews/${p.id}`);
|
|
27
|
+
console.log(yellow(` ${s.platform}/${code}/${s.type}: removed old preview ${p.id}`));
|
|
28
|
+
}
|
|
29
|
+
if (!set) {
|
|
30
|
+
const c = await client.post(`/v1/appPreviewSets`, { data: { type: "appPreviewSets", attributes: { previewType: s.type }, relationships: { appStoreVersionLocalization: { data: { type: "appStoreVersionLocalizations", id: loc.id } } } } });
|
|
31
|
+
set = c.json.data;
|
|
32
|
+
}
|
|
33
|
+
const file = path.resolve(s.file);
|
|
34
|
+
console.log(` ${s.platform}/${code}/${s.type}: uploading ${path.basename(file)}...`);
|
|
35
|
+
const id = await uploadAsset(client, { type: "appPreviews", setType: "appPreviewSet", setId: set.id, filePath: file });
|
|
36
|
+
await setPreviewPoster(client, id, s.poster);
|
|
37
|
+
console.log(green(` done ${s.platform}/${code}/${s.type}`));
|
|
38
|
+
} catch (e) {
|
|
39
|
+
console.error(red(` error ${s.platform}/${code}/${s.type}: ${e.message}`));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function defaultSpecs(config) {
|
|
47
|
+
return [
|
|
48
|
+
{ platform: "IOS", type: "IPHONE_67", file: "marketing/out/appstore-preview.mp4", poster: "00:00:05:00", locales: [config.primaryLocale, "en-US"] },
|
|
49
|
+
{ platform: "MAC_OS", type: "DESKTOP", file: "marketing/out/mac-appstore-preview-mac.mp4", poster: "00:00:03:00", locales: [config.primaryLocale, "en-US"] },
|
|
50
|
+
].filter((s) => config.platforms.includes(s.platform) && fs.existsSync(path.resolve(s.file)));
|
|
51
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { green, yellow } from "../util.mjs";
|
|
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" };
|
|
8
|
+
|
|
9
|
+
export async function run(config) {
|
|
10
|
+
const collected = config.privacy?.collected || ["CRASH_DATA", "PERFORMANCE_DATA"];
|
|
11
|
+
const tracking = !!config.privacy?.tracking;
|
|
12
|
+
const record = collected.map((cat) => ({
|
|
13
|
+
category: cat, purposes: ["APP_FUNCTIONALITY"],
|
|
14
|
+
data_protections: [tracking ? "DATA_LINKED_TO_YOU" : "DATA_NOT_LINKED_TO_YOU", tracking ? "DATA_USED_TO_TRACK_YOU" : null].filter(Boolean),
|
|
15
|
+
}));
|
|
16
|
+
const out = path.join(path.dirname(config.metadataDir), "app_privacy_details.json");
|
|
17
|
+
fs.writeFileSync(out, JSON.stringify(record, null, 2));
|
|
18
|
+
console.log(green(`wrote ${out} (declaration record)`));
|
|
19
|
+
console.log();
|
|
20
|
+
console.log(yellow("App Privacy is UI-only (passkey) — the API key 401s on Apple's iris host. Enter:"));
|
|
21
|
+
if (collected.length) console.log(" Do you or your partners collect data? -> Yes");
|
|
22
|
+
for (const c of collected) {
|
|
23
|
+
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"}`);
|
|
25
|
+
}
|
|
26
|
+
console.log(" Everything else -> Not Collected.");
|
|
27
|
+
console.log(yellow(" 'accesses' is not 'collects' — E2EE content you can't read is not collected."));
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { green, red } from "../util.mjs";
|
|
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.
|
|
7
|
+
export async function run(config, client) {
|
|
8
|
+
const dir = path.join(config.metadataDir, "review_information");
|
|
9
|
+
const read = (n) => { const p = path.join(dir, `${n}.txt`); return fs.existsSync(p) ? fs.readFileSync(p, "utf8").trim() : ""; };
|
|
10
|
+
const attributes = {
|
|
11
|
+
contactFirstName: read("first_name"), contactLastName: read("last_name"),
|
|
12
|
+
contactPhone: read("phone_number"), contactEmail: read("email_address"),
|
|
13
|
+
demoAccountRequired: false, notes: read("notes"),
|
|
14
|
+
};
|
|
15
|
+
if (!attributes.contactEmail) { console.error(red(`review-contact: ${dir}/*.txt missing`)); return false; }
|
|
16
|
+
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; }
|
|
25
|
+
console.log(green(`review contact set -> ${attributes.contactFirstName} ${attributes.contactLastName} · ${attributes.contactPhone}`));
|
|
26
|
+
return true;
|
|
27
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { resolveLocales } from "./locales.mjs";
|
|
5
|
+
|
|
6
|
+
// The public config surface — the drift guards assert each key is documented (README/SKILL) and typed
|
|
7
|
+
// (types/index.d.ts). Add a config knob → document + type it, or the guards fail before publish.
|
|
8
|
+
export const CONFIG_KEYS = ["bundleId", "primaryLocale", "asc", "platforms", "uiLocales", "metadataDir", "rating", "privacy", "iaps", "previews", "export", "google"];
|
|
9
|
+
|
|
10
|
+
// One `vydanne.config.mjs` per app (ESM, like zdymak.config.mjs) — nothing hard-coded. Secrets stay out:
|
|
11
|
+
// ASC key via ~/.appstoreconnect + ASC_KEY_ID/ASC_ISSUER_ID env; review-contact PII in gitignored files.
|
|
12
|
+
export async function loadConfig(p) {
|
|
13
|
+
const file = path.resolve(p || process.env.VYDANNE_CONFIG || "vydanne.config.mjs");
|
|
14
|
+
if (!fs.existsSync(file)) throw new Error(`vydanne: config not found at ${file}`);
|
|
15
|
+
const mod = await import(pathToFileURL(file).href);
|
|
16
|
+
const raw = mod.default || mod;
|
|
17
|
+
const need = (k) => {
|
|
18
|
+
if (raw[k] == null) throw new Error(`vydanne: config missing '${k}'`);
|
|
19
|
+
return raw[k];
|
|
20
|
+
};
|
|
21
|
+
const c = {
|
|
22
|
+
raw,
|
|
23
|
+
bundleId: need("bundleId"),
|
|
24
|
+
primaryLocale: need("primaryLocale"),
|
|
25
|
+
keyId: process.env.ASC_KEY_ID || raw.asc?.keyId,
|
|
26
|
+
issuerId: process.env.ASC_ISSUER_ID || raw.asc?.issuerId,
|
|
27
|
+
uiLocales: raw.uiLocales || [],
|
|
28
|
+
platforms: raw.platforms || ["IOS"],
|
|
29
|
+
rating: raw.rating || "4+",
|
|
30
|
+
privacy: raw.privacy || { collected: ["CRASH_DATA", "PERFORMANCE_DATA"], tracking: false },
|
|
31
|
+
iaps: raw.iaps || [],
|
|
32
|
+
metadataDir: raw.metadataDir || "fastlane/metadata",
|
|
33
|
+
previews: raw.previews || null,
|
|
34
|
+
export: raw.export || { encryption: "standard" },
|
|
35
|
+
// Google Play. serviceAccountKey resolves from PLAY_JSON_KEY_FILE env first (keep the secret path out
|
|
36
|
+
// of the committed config). metadataDir follows fastlane supply's convention (fastlane/metadata/android).
|
|
37
|
+
google: raw.google
|
|
38
|
+
? {
|
|
39
|
+
packageName: raw.google.packageName || raw.bundleId,
|
|
40
|
+
serviceAccountKey: process.env.PLAY_JSON_KEY_FILE || raw.google.serviceAccountKey,
|
|
41
|
+
metadataDir: raw.google.metadataDir || "fastlane/metadata/android",
|
|
42
|
+
defaultLocale: raw.google.defaultLocale || raw.primaryLocale,
|
|
43
|
+
}
|
|
44
|
+
: null,
|
|
45
|
+
};
|
|
46
|
+
c.resolvedLocales = resolveLocales(c.uiLocales);
|
|
47
|
+
return c;
|
|
48
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Programmatic entry (the CLI is bin/vydanne.mjs). `import { Client, loadConfig } from "vydanne"`.
|
|
2
|
+
export { Client } from "./client.mjs";
|
|
3
|
+
export { loadConfig, CONFIG_KEYS } from "./config.mjs";
|
|
4
|
+
export { COMMANDS, COMMAND_NAMES } from "./registry.mjs";
|
|
5
|
+
export { resolveLocales, toAsc, VALID } from "./locales.mjs";
|
|
6
|
+
export { makeToken } from "./jwt.mjs";
|
package/src/jwt.mjs
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
// App Store Connect JWT (ES256). Node's built-in crypto signs it — `dsaEncoding: "ieee-p1363"` returns the
|
|
7
|
+
// raw r||s signature JWT/JOSE needs (the default DER encoding would be rejected). No fastlane/spaceship.
|
|
8
|
+
export function makeToken({ keyId, issuerId, keyPath }) {
|
|
9
|
+
keyPath ||= path.join(os.homedir(), ".appstoreconnect", "private_keys", `AuthKey_${keyId}.p8`);
|
|
10
|
+
if (!fs.existsSync(keyPath)) throw new Error(`vydanne: ASC key not found at ${keyPath}`);
|
|
11
|
+
const p8 = fs.readFileSync(keyPath, "utf8");
|
|
12
|
+
const b64u = (o) => Buffer.from(typeof o === "string" ? o : JSON.stringify(o)).toString("base64url");
|
|
13
|
+
const now = Math.floor(Date.now() / 1000);
|
|
14
|
+
const input = `${b64u({ alg: "ES256", kid: keyId, typ: "JWT" })}.${b64u({ iss: issuerId, iat: now, exp: now + 1200, aud: "appstoreconnect-v1" })}`;
|
|
15
|
+
const sig = crypto.sign("sha256", Buffer.from(input), { key: p8, dsaEncoding: "ieee-p1363" });
|
|
16
|
+
return `${input}.${sig.toString("base64url")}`;
|
|
17
|
+
}
|
package/src/locales.mjs
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// App Store locale handling — the #1 gotcha: deliver/ASC folder codes must match Apple's exact list, and a
|
|
2
|
+
// short UI code (`de`, `ar`) that isn't valid aborts a whole upload. A code with no App Store language
|
|
3
|
+
// (e.g. Belarusian `be`) must fall back to the primary listing.
|
|
4
|
+
export const VALID = new Set([
|
|
5
|
+
"ar-SA", "bn-BD", "ca", "cs", "da", "de-DE", "el", "en-AU", "en-CA", "en-GB", "en-US", "es-ES", "es-MX",
|
|
6
|
+
"fi", "fr-CA", "fr-FR", "gu-IN", "he", "hi", "hr", "hu", "id", "it", "ja", "kn-IN", "ko", "ml-IN", "mr-IN",
|
|
7
|
+
"ms", "nl-NL", "no", "or-IN", "pa-IN", "pl", "pt-BR", "pt-PT", "ro", "ru", "sk", "sl-SI", "sv", "ta-IN",
|
|
8
|
+
"te-IN", "th", "tr", "uk", "ur-PK", "vi", "zh-Hans", "zh-Hant",
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
export const UI_TO_ASC = {
|
|
12
|
+
ar: "ar-SA", bn: "bn-BD", de: "de-DE", es: "es-ES", fr: "fr-FR", nl: "nl-NL",
|
|
13
|
+
pt: "pt-BR", ur: "ur-PK", zh: "zh-Hans", en: "en-US",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const toAsc = (code) => (VALID.has(code) ? code : UI_TO_ASC[code] || null);
|
|
17
|
+
|
|
18
|
+
export function resolveLocales(uiCodes = []) {
|
|
19
|
+
const supported = {};
|
|
20
|
+
const unsupported = [];
|
|
21
|
+
for (const c of uiCodes) {
|
|
22
|
+
const asc = toAsc(c);
|
|
23
|
+
asc ? (supported[c] = asc) : unsupported.push(c);
|
|
24
|
+
}
|
|
25
|
+
return { supported, unsupported };
|
|
26
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
|
|
4
|
+
const SCOPE = "https://www.googleapis.com/auth/androidpublisher";
|
|
5
|
+
|
|
6
|
+
// Google Play uses OAuth2 two-legged (service account): sign an RS256 JWT with the SA private key, exchange
|
|
7
|
+
// it at the token endpoint for a ~1h access token. Native crypto — no googleapis lib. (Apple is per-request
|
|
8
|
+
// ES256; Google is a token exchange — the one real auth difference between the two stores.)
|
|
9
|
+
export async function getAccessToken(keyPath) {
|
|
10
|
+
if (!fs.existsSync(keyPath)) throw new Error(`vydanne: Play service-account key not found at ${keyPath}`);
|
|
11
|
+
const sa = JSON.parse(fs.readFileSync(keyPath, "utf8"));
|
|
12
|
+
if (sa.type !== "service_account") {
|
|
13
|
+
throw new Error(`vydanne: ${keyPath} is not a service-account key (type=${sa.type || "?"}). ` +
|
|
14
|
+
"You need a Google Cloud service account with the Android Publisher role — NOT an OAuth client_secret.");
|
|
15
|
+
}
|
|
16
|
+
const b64u = (o) => Buffer.from(typeof o === "string" ? o : JSON.stringify(o)).toString("base64url");
|
|
17
|
+
const now = Math.floor(Date.now() / 1000);
|
|
18
|
+
const claim = { iss: sa.client_email, scope: SCOPE, aud: sa.token_uri, iat: now, exp: now + 3600 };
|
|
19
|
+
const input = `${b64u({ alg: "RS256", typ: "JWT" })}.${b64u(claim)}`;
|
|
20
|
+
const sig = crypto.sign("RSA-SHA256", Buffer.from(input), sa.private_key).toString("base64url");
|
|
21
|
+
const res = await fetch(sa.token_uri, {
|
|
22
|
+
method: "POST",
|
|
23
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
24
|
+
body: new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion: `${input}.${sig}` }),
|
|
25
|
+
});
|
|
26
|
+
const j = await res.json();
|
|
27
|
+
if (!j.access_token) throw new Error(`vydanne: Play token exchange failed: ${JSON.stringify(j).slice(0, 300)}`);
|
|
28
|
+
return j.access_token;
|
|
29
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { getAccessToken } from "./auth.mjs";
|
|
4
|
+
|
|
5
|
+
const BASE = "https://androidpublisher.googleapis.com/androidpublisher/v3";
|
|
6
|
+
const UPLOAD = "https://androidpublisher.googleapis.com/upload/androidpublisher/v3";
|
|
7
|
+
|
|
8
|
+
// Google Play Developer API client. THE key difference from Apple: everything goes through an **Edit**
|
|
9
|
+
// transaction — insert an edit, mutate listings/images/details against it, then commit (all-or-nothing).
|
|
10
|
+
// Nothing is live until commit; a dropped edit changes nothing. Image bytes go to the /upload endpoint.
|
|
11
|
+
export class PlayClient {
|
|
12
|
+
static async create({ keyPath, packageName }) {
|
|
13
|
+
const token = await getAccessToken(keyPath);
|
|
14
|
+
return new PlayClient(token, packageName);
|
|
15
|
+
}
|
|
16
|
+
constructor(token, packageName) {
|
|
17
|
+
this.token = token;
|
|
18
|
+
this.pkg = packageName;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async req(method, subpath, { body, base = BASE } = {}) {
|
|
22
|
+
const res = await fetch(`${base}/applications/${this.pkg}${subpath}`, {
|
|
23
|
+
method,
|
|
24
|
+
headers: { Authorization: `Bearer ${this.token}`, ...(body ? { "Content-Type": "application/json" } : {}) },
|
|
25
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
26
|
+
});
|
|
27
|
+
const text = await res.text();
|
|
28
|
+
let json; try { json = text ? JSON.parse(text) : {}; } catch { json = text; }
|
|
29
|
+
return { status: res.status, json };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── Edit transaction ──────────────────────────────────────────────────────────────────────────────
|
|
33
|
+
async newEdit() {
|
|
34
|
+
const { status, json } = await this.req("POST", "/edits", { body: {} });
|
|
35
|
+
if (status >= 300) throw new Error(`edits.insert ${status}: ${JSON.stringify(json).slice(0, 200)}`);
|
|
36
|
+
return json.id;
|
|
37
|
+
}
|
|
38
|
+
validate(editId) { return this.req("POST", `/edits/${editId}:validate`, { body: {} }); }
|
|
39
|
+
commit(editId) { return this.req("POST", `/edits/${editId}:commit`, { body: {} }); }
|
|
40
|
+
deleteEdit(editId) { return this.req("DELETE", `/edits/${editId}`); }
|
|
41
|
+
|
|
42
|
+
// ── Listings (per language) ──────────────────────────────────────────────────────────────────────
|
|
43
|
+
getListings(editId) { return this.req("GET", `/edits/${editId}/listings`); }
|
|
44
|
+
// body: { language, title, shortDescription, fullDescription, video }
|
|
45
|
+
putListing(editId, language, body) { return this.req("PUT", `/edits/${editId}/listings/${language}`, { body: { language, ...body } }); }
|
|
46
|
+
|
|
47
|
+
// ── Details (default language + contact) ─────────────────────────────────────────────────────────
|
|
48
|
+
getDetails(editId) { return this.req("GET", `/edits/${editId}/details`); }
|
|
49
|
+
patchDetails(editId, body) { return this.req("PATCH", `/edits/${editId}/details`, { body }); }
|
|
50
|
+
|
|
51
|
+
// ── Images ───────────────────────────────────────────────────────────────────────────────────────
|
|
52
|
+
// imageType: phoneScreenshots | sevenInchScreenshots | tenInchScreenshots | wearScreenshots |
|
|
53
|
+
// tvScreenshots | icon | featureGraphic | promoGraphic | tvBanner
|
|
54
|
+
listImages(editId, language, imageType) { return this.req("GET", `/edits/${editId}/listings/${language}/${imageType}`); }
|
|
55
|
+
deleteAllImages(editId, language, imageType) { return this.req("DELETE", `/edits/${editId}/listings/${language}/${imageType}`); }
|
|
56
|
+
async uploadImage(editId, language, imageType, filePath) {
|
|
57
|
+
const bytes = fs.readFileSync(filePath);
|
|
58
|
+
const mime = path.extname(filePath).toLowerCase() === ".jpg" || path.extname(filePath).toLowerCase() === ".jpeg" ? "image/jpeg" : "image/png";
|
|
59
|
+
const res = await fetch(`${UPLOAD}/applications/${this.pkg}/edits/${editId}/listings/${language}/${imageType}?uploadType=media`, {
|
|
60
|
+
method: "POST",
|
|
61
|
+
headers: { Authorization: `Bearer ${this.token}`, "Content-Type": mime },
|
|
62
|
+
body: bytes,
|
|
63
|
+
});
|
|
64
|
+
const j = await res.json().catch(() => ({}));
|
|
65
|
+
if (res.status >= 300) throw new Error(`image upload ${res.status}: ${JSON.stringify(j).slice(0, 200)}`);
|
|
66
|
+
return j;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { green, red, yellow } from "../../util.mjs";
|
|
4
|
+
|
|
5
|
+
// [Play listing attribute, local metadata filename] — supply's convention under fastlane/metadata/android.
|
|
6
|
+
const FIELDS = [["title", "title"], ["shortDescription", "short_description"], ["fullDescription", "full_description"]];
|
|
7
|
+
const norm = (s) => (s == null ? null : String(s).replace(/\r/g, "").replace(/\n+$/, "").trim());
|
|
8
|
+
|
|
9
|
+
// Show what differs between local Play sources (fastlane/metadata/android/<locale>/*.txt) and the live Play
|
|
10
|
+
// listing — a dry-run of `fill --store google`.
|
|
11
|
+
export async function run(config, client) {
|
|
12
|
+
const g = config.google;
|
|
13
|
+
const editId = await client.newEdit();
|
|
14
|
+
let actionable = 0;
|
|
15
|
+
try {
|
|
16
|
+
const listings = (await client.getListings(editId)).json.listings || [];
|
|
17
|
+
const remote = Object.fromEntries(listings.map((l) => [l.language, l]));
|
|
18
|
+
const dir = g.metadataDir;
|
|
19
|
+
const localLangs = fs.existsSync(dir) ? fs.readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name) : [];
|
|
20
|
+
|
|
21
|
+
if (!localLangs.length) {
|
|
22
|
+
console.log(yellow(` no local listing folders under ${dir} (Android Phase 2 — nothing to push yet)`));
|
|
23
|
+
}
|
|
24
|
+
for (const lang of localLangs) {
|
|
25
|
+
const read = (f) => { const p = path.join(dir, lang, `${f}.txt`); return fs.existsSync(p) ? norm(fs.readFileSync(p, "utf8")) : null; };
|
|
26
|
+
const R = remote[lang] || {};
|
|
27
|
+
const out = [];
|
|
28
|
+
if (!remote[lang]) { out.push(yellow("[fill would create]")); }
|
|
29
|
+
for (const [attr, file] of FIELDS) {
|
|
30
|
+
const L = read(file), Rv = norm(R[attr]);
|
|
31
|
+
if (L == null && (Rv == null || Rv === "")) continue;
|
|
32
|
+
if (L == null) out.push(`${file} ${yellow("[remote-only]")}`);
|
|
33
|
+
else if (!Rv) out.push(`${file} ${green("[local-only]")}`);
|
|
34
|
+
else if (L !== Rv) { out.push(`${file} ${red("differs")}`); actionable++; }
|
|
35
|
+
}
|
|
36
|
+
if (out.length) console.log(` ${lang}: ${out.join(" · ")}`);
|
|
37
|
+
}
|
|
38
|
+
const localSet = new Set(localLangs);
|
|
39
|
+
const extra = listings.map((l) => l.language).filter((x) => !localSet.has(x));
|
|
40
|
+
if (extra.length) console.log(` ${yellow("Play-only languages")} (no local folder): ${extra.join(", ")}`);
|
|
41
|
+
} finally {
|
|
42
|
+
await client.deleteEdit(editId);
|
|
43
|
+
}
|
|
44
|
+
console.log();
|
|
45
|
+
console.log(actionable ? yellow(`${actionable} actionable difference(s) — run \`fill --store google\` to sync`) : green("in sync — local matches Play"));
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { green, yellow, red } from "../../util.mjs";
|
|
4
|
+
|
|
5
|
+
const FIELDS = [["title", "title"], ["shortDescription", "short_description"], ["fullDescription", "full_description"]];
|
|
6
|
+
// Play image type -> local source (a dir of PNGs = screenshots; a single file = graphic). From zdymak.
|
|
7
|
+
// Each type is uploaded only when its local asset EXISTS, so an app that lacks (say) tablet shots or a
|
|
8
|
+
// znachok icon simply skips that type — a missing local set never deletes the live one.
|
|
9
|
+
const IMAGES = [
|
|
10
|
+
["icon", "brand/icons/play/icon-512.png", "file"],
|
|
11
|
+
["featureGraphic", "marketing/out/play-feature-graphic.png", "file"],
|
|
12
|
+
["phoneScreenshots", "marketing/out/play-phone-plain", "dir"],
|
|
13
|
+
["sevenInchScreenshots", "marketing/out/play-tablet7-plain", "dir"],
|
|
14
|
+
["tenInchScreenshots", "marketing/out/play-tablet-plain", "dir"],
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
// Push the Play listing (text + images) inside one Edit, then validate and commit. iOS/Android are separate
|
|
18
|
+
// stores — this is the Google half. Images only touch a type whose local asset EXISTS (so a missing local
|
|
19
|
+
// set never deletes the live one). VYDANNE_DRY=1 validates and discards without committing.
|
|
20
|
+
export async function run(config, client) {
|
|
21
|
+
const g = config.google;
|
|
22
|
+
// SAFE BY DEFAULT: validate + discard the edit unless VYDANNE_COMMIT=1. A store-mutating commit must be
|
|
23
|
+
// an explicit opt-in — never the default (a stale/partial local set could otherwise clobber a live one).
|
|
24
|
+
const commit = process.env.VYDANNE_COMMIT === "1";
|
|
25
|
+
const localLangs = fs.existsSync(g.metadataDir)
|
|
26
|
+
? fs.readdirSync(g.metadataDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name)
|
|
27
|
+
: [];
|
|
28
|
+
const haveImages = IMAGES.some(([, src]) => fs.existsSync(src));
|
|
29
|
+
if (!localLangs.length && !haveImages) {
|
|
30
|
+
console.log(yellow(`fill(play): no local listing folders under ${g.metadataDir} and no zdymak play assets — nothing to upload yet (populate them for Android Phase 2).`));
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const editId = await client.newEdit();
|
|
35
|
+
try {
|
|
36
|
+
// Listing text
|
|
37
|
+
for (const lang of localLangs) {
|
|
38
|
+
const read = (f) => { const p = path.join(g.metadataDir, lang, `${f}.txt`); return fs.existsSync(p) ? fs.readFileSync(p, "utf8").replace(/\n+$/, "") : undefined; };
|
|
39
|
+
const body = {};
|
|
40
|
+
for (const [attr, file] of FIELDS) { const t = read(file); if (t !== undefined) body[attr] = t; }
|
|
41
|
+
if (Object.keys(body).length) {
|
|
42
|
+
const r = await client.putListing(editId, lang, body);
|
|
43
|
+
if (r.status >= 300) throw new Error(`listing ${lang} ${r.status}: ${JSON.stringify(r.json).slice(0, 160)}`);
|
|
44
|
+
console.log(green(` ${lang}: listing (${Object.keys(body).join(", ")})`));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// Images — replace a type only when the local asset exists (delete-all then upload).
|
|
48
|
+
const lang = g.defaultLocale;
|
|
49
|
+
for (const [type, src, kind] of IMAGES) {
|
|
50
|
+
if (!fs.existsSync(src)) continue;
|
|
51
|
+
const files = kind === "dir" ? fs.readdirSync(src).filter((f) => /\.(png|jpe?g)$/i.test(f)).sort().map((f) => path.join(src, f)) : [src];
|
|
52
|
+
if (!files.length) continue;
|
|
53
|
+
await client.deleteAllImages(editId, lang, type);
|
|
54
|
+
for (const f of files) await client.uploadImage(editId, lang, type, f);
|
|
55
|
+
console.log(green(` ${lang}/${type}: ${files.length} image(s)`));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const v = await client.validate(editId);
|
|
59
|
+
if (v.status >= 300) throw new Error(`validate ${v.status}: ${JSON.stringify(v.json).slice(0, 200)}`);
|
|
60
|
+
if (!commit) { await client.deleteEdit(editId); console.log(yellow("fill(play): validated — DRY (nothing changed). Review the above, then set VYDANNE_COMMIT=1 to commit.")); return true; }
|
|
61
|
+
const co = await client.commit(editId);
|
|
62
|
+
if (co.status >= 300) throw new Error(`commit ${co.status}: ${JSON.stringify(co.json).slice(0, 200)}`);
|
|
63
|
+
console.log(green("fill(play): committed."));
|
|
64
|
+
} catch (e) {
|
|
65
|
+
await client.deleteEdit(editId).catch(() => {});
|
|
66
|
+
throw e;
|
|
67
|
+
}
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const IMAGE_TYPES = ["phoneScreenshots", "sevenInchScreenshots", "tenInchScreenshots", "wearScreenshots", "tvScreenshots", "featureGraphic", "icon", "promoGraphic"];
|
|
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`.
|
|
5
|
+
export async function run(config, client) {
|
|
6
|
+
const g = config.google;
|
|
7
|
+
const editId = await client.newEdit();
|
|
8
|
+
try {
|
|
9
|
+
const details = (await client.getDetails(editId)).json;
|
|
10
|
+
const listings = (await client.getListings(editId)).json.listings || [];
|
|
11
|
+
console.log(`PLAY ${g.packageName} default=${details.defaultLanguage} contact=${details.contactEmail || "-"}`);
|
|
12
|
+
console.log(` listings (${listings.length}): ${listings.map((l) => l.language).join(", ") || "(none)"}`);
|
|
13
|
+
const lang = details.defaultLanguage || g.defaultLocale;
|
|
14
|
+
const counts = [];
|
|
15
|
+
for (const t of IMAGE_TYPES) {
|
|
16
|
+
const imgs = (await client.listImages(editId, lang, t)).json.images || [];
|
|
17
|
+
if (imgs.length) counts.push(`${t}=${imgs.length}`);
|
|
18
|
+
}
|
|
19
|
+
console.log(` images (${lang}): ${counts.join(" ") || "(none)"}`);
|
|
20
|
+
} finally {
|
|
21
|
+
await client.deleteEdit(editId);
|
|
22
|
+
}
|
|
23
|
+
return true;
|
|
24
|
+
}
|