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.
@@ -0,0 +1,37 @@
1
+ import { green, red, yellow } from "../../util.mjs";
2
+
3
+ // Play listing limits.
4
+ const LIMITS = { title: 30, shortDescription: 80, fullDescription: 4000 };
5
+
6
+ // Verify the Play listing is submission-complete: default-language title/short/full present + within limits,
7
+ // the required feature graphic, and >=2 phone screenshots. Reads via a throwaway edit.
8
+ export async function run(config, client) {
9
+ const g = config.google;
10
+ const editId = await client.newEdit();
11
+ const problems = [];
12
+ try {
13
+ const details = (await client.getDetails(editId)).json;
14
+ const lang = details.defaultLanguage || g.defaultLocale;
15
+ console.log(` PLAY ${g.packageName} default=${lang}`);
16
+ const listings = (await client.getListings(editId)).json.listings || [];
17
+ const dl = listings.find((l) => l.language === lang);
18
+ if (!dl) problems.push(`default language ${lang}: no listing`);
19
+ else {
20
+ for (const [f, lim] of Object.entries(LIMITS)) {
21
+ const v = (dl[f] || "").trim();
22
+ if (!v) problems.push(`${lang}: ${f} empty`);
23
+ else if (v.length > lim) problems.push(`${lang}: ${f} ${v.length}>${lim}`);
24
+ }
25
+ }
26
+ const fg = (await client.listImages(editId, lang, "featureGraphic")).json.images || [];
27
+ if (!fg.length) problems.push(`${lang}: feature graphic missing (required)`);
28
+ const ph = (await client.listImages(editId, lang, "phoneScreenshots")).json.images || [];
29
+ if (ph.length < 2) problems.push(`${lang}: needs >=2 phone screenshots (have ${ph.length})`);
30
+ } finally {
31
+ await client.deleteEdit(editId);
32
+ }
33
+ console.log();
34
+ if (!problems.length) console.log(green("preflight: no blockers"));
35
+ else { console.log(red(`preflight: ${problems.length} blocker(s)`)); problems.forEach((p) => console.log(` ${red("x")} ${p}`)); }
36
+ return problems.length === 0;
37
+ }
@@ -0,0 +1,29 @@
1
+ // The canonical command registry — the single source of vydanne's public commands. bin/ dispatches from
2
+ // this, and the drift guards (scripts/check-docs.mjs, scripts/check-types.mjs) assert every command is
3
+ // documented in README/SKILL and typed in types/index.d.ts. Add a command here → the guards force it into
4
+ // the docs + types before publish. name -> { mod: <file in src/commands>, client: needs an ASC client }
5
+ export const COMMANDS = {
6
+ fill: { mod: "fill", client: true },
7
+ "age-rating": { mod: "ageRating", client: true },
8
+ "review-contact": { mod: "reviewContact", client: true },
9
+ accessibility: { mod: "accessibility", client: true },
10
+ privacy: { mod: "privacy", client: false },
11
+ previews: { mod: "previews", client: true },
12
+ iap: { mod: "iap", client: false },
13
+ compliance: { mod: "compliance", client: false },
14
+ inspect: { mod: "inspect", client: true },
15
+ diff: { mod: "diff", client: true },
16
+ preflight: { mod: "preflight", client: true },
17
+ };
18
+
19
+ // Commands available for `--store google` (Google Play). Same names as the Apple ones, different backend
20
+ // (src/play/commands/). Store-specific commands (Apple accessibility/iap; Play data-safety) aren't shared.
21
+ export const PLAY_COMMANDS = {
22
+ inspect: { mod: "inspect" },
23
+ preflight: { mod: "preflight" },
24
+ diff: { mod: "diff" },
25
+ fill: { mod: "fill" },
26
+ };
27
+
28
+ // Full public command surface (the module-dispatched ones above + the two handled inline in bin/).
29
+ export const COMMAND_NAMES = [...Object.keys(COMMANDS), "locales", "version"];
package/src/upload.mjs ADDED
@@ -0,0 +1,47 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ const md5 = (buf) => crypto.createHash("md5").update(buf).digest("hex");
6
+
7
+ // Native ASC asset upload (what spaceship does internally): reserve the asset (returns pre-signed
8
+ // uploadOperations) -> PUT each chunk -> commit with uploaded:true + the MD5 checksum.
9
+ // `type` = "appScreenshots" | "appPreviews"; `setType` = "appScreenshotSet" | "appPreviewSet".
10
+ export async function uploadAsset(client, { type, setType, setId, filePath }) {
11
+ const bytes = fs.readFileSync(filePath);
12
+ const reserve = await client.post(`/v1/${type}`, {
13
+ data: {
14
+ type,
15
+ attributes: { fileSize: bytes.length, fileName: path.basename(filePath) },
16
+ relationships: { [setType]: { data: { type: `${setType}s`, id: setId } } },
17
+ },
18
+ });
19
+ if (reserve.status >= 300) throw new Error(`reserve ${type} ${reserve.status}: ${JSON.stringify(reserve.json).slice(0, 200)}`);
20
+ const asset = reserve.json.data;
21
+ for (const op of asset.attributes.uploadOperations || []) {
22
+ const headers = {};
23
+ for (const h of op.requestHeaders || []) headers[h.name] = h.value;
24
+ const r = await fetch(op.url, { method: op.method, headers, body: bytes.subarray(op.offset, op.offset + op.length) });
25
+ if (r.status >= 300) throw new Error(`upload op ${r.status} for ${path.basename(filePath)}`);
26
+ }
27
+ const commit = await client.patch(`/v1/${type}/${asset.id}`, {
28
+ data: { type, id: asset.id, attributes: { uploaded: true, sourceFileChecksum: md5(bytes) } },
29
+ });
30
+ if (commit.status >= 300) throw new Error(`commit ${type} ${commit.status}: ${JSON.stringify(commit.json).slice(0, 200)}`);
31
+ return asset.id;
32
+ }
33
+
34
+ // Previews process asynchronously — poll until Apple exposes videoUrl, then set the poster frame.
35
+ export async function setPreviewPoster(client, previewId, frameTimeCode, { tries = 30, delayMs = 15000 } = {}) {
36
+ for (let i = 0; i < tries; i++) {
37
+ const { json } = await client.get(`/v1/appPreviews/${previewId}`);
38
+ if (json.data?.attributes?.videoUrl) {
39
+ await client.patch(`/v1/appPreviews/${previewId}`, {
40
+ data: { type: "appPreviews", id: previewId, attributes: { previewFrameTimeCode: frameTimeCode } },
41
+ });
42
+ return true;
43
+ }
44
+ await new Promise((r) => setTimeout(r, delayMs));
45
+ }
46
+ return false;
47
+ }
package/src/util.mjs ADDED
@@ -0,0 +1,9 @@
1
+ export const green = (s) => `\x1b[32m${s}\x1b[0m`;
2
+ export const red = (s) => `\x1b[31m${s}\x1b[0m`;
3
+ export const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
4
+
5
+ // App Store char limits (validated before upload so nothing eats a mid-upload rejection).
6
+ export const LIMITS = { name: 30, subtitle: 30, keywords: 100, promotional_text: 170, iap_display_name: 30, iap_description: 45 };
7
+
8
+ // Version-localization text fields (name/subtitle live on AppInfo). key = the ASC camelCase attribute.
9
+ export const VERSION_FIELDS = { description: "description", keywords: "keywords", promotional_text: "promotionalText" };
@@ -0,0 +1,119 @@
1
+ // Type definitions for vydanne — App Store Connect submission prep (companion to zdymak).
2
+ // Author a config with: /** @type {import('vydanne').VydanneConfig} */ export default { … }
3
+
4
+ export type Platform = 'IOS' | 'MAC_OS';
5
+
6
+ /** Every vydanne CLI command (kept in sync with src/registry.mjs by scripts/check-types.mjs). */
7
+ export type CommandName =
8
+ | 'fill'
9
+ | 'age-rating'
10
+ | 'review-contact'
11
+ | 'accessibility'
12
+ | 'privacy'
13
+ | 'previews'
14
+ | 'iap'
15
+ | 'compliance'
16
+ | 'inspect'
17
+ | 'diff'
18
+ | 'preflight'
19
+ | 'locales'
20
+ | 'version';
21
+
22
+ export interface IapConfig {
23
+ productId: string;
24
+ /** 'non_consumable' | 'consumable' | 'auto_renewable' … */
25
+ type: string;
26
+ price: number;
27
+ /** App Store facing — max 30 chars. */
28
+ displayName: string;
29
+ /** App Store facing — max 45 chars. */
30
+ description: string;
31
+ reviewNote?: string;
32
+ }
33
+
34
+ export interface PreviewSpec {
35
+ platform: Platform;
36
+ /** Apple PreviewType, e.g. 'IPHONE_67' (6.9") | 'IPHONE_65' | 'IPAD_PRO_3GEN_129' | 'DESKTOP'. */
37
+ type: string;
38
+ /** Path to the .mp4 (from zdymak). */
39
+ file: string;
40
+ /** Poster frame, 'HH:MM:SS:FF'. */
41
+ poster?: string;
42
+ /** ASC locale codes to attach the preview to; defaults to the primary locale. */
43
+ locales?: string[];
44
+ }
45
+
46
+ export interface PrivacyConfig {
47
+ /** ASC data-type ids, e.g. ['CRASH_DATA', 'PERFORMANCE_DATA']. */
48
+ collected: string[];
49
+ tracking: boolean;
50
+ }
51
+
52
+ export interface GoogleConfig {
53
+ /** Play package name (usually the same as the iOS bundle id). */
54
+ packageName: string;
55
+ /** Path to the Play service-account JSON. Prefer the PLAY_JSON_KEY_FILE env over committing a path. */
56
+ serviceAccountKey?: string;
57
+ /** Listing-text folders, supply convention. Default 'fastlane/metadata/android'. */
58
+ metadataDir?: string;
59
+ defaultLocale?: string;
60
+ }
61
+
62
+ export interface ExportConfig {
63
+ /** 'standard' → self-classify (ECCN 5D002, ENC 740.17(b)(1)); else no compliance doc is generated. */
64
+ encryption: string;
65
+ /** Generate/expect the France ANSSI declaration for the France territory. */
66
+ france?: boolean;
67
+ appName?: string;
68
+ version?: string;
69
+ teamId?: string;
70
+ }
71
+
72
+ /** The `vydanne.config.mjs` default export. */
73
+ export interface VydanneConfig {
74
+ bundleId: string;
75
+ /** Fallback for every locale without its own listing — must be populated. */
76
+ primaryLocale: string;
77
+ /** Prefer ASC_KEY_ID / ASC_ISSUER_ID env over putting these in the config. */
78
+ asc?: { keyId?: string; issuerId?: string };
79
+ /** iOS and macOS are separate ASC platforms. */
80
+ platforms?: Platform[];
81
+ /** App UI locales; mapped to ASC codes (unsupported ones fall back to primary). */
82
+ uiLocales?: string[];
83
+ metadataDir?: string;
84
+ /** e.g. '4+'. */
85
+ rating?: string;
86
+ privacy?: PrivacyConfig;
87
+ iaps?: IapConfig[];
88
+ previews?: PreviewSpec[];
89
+ export?: ExportConfig;
90
+ /** Google Play (`--store google`): listings, screenshots, feature graphic via the Edits API. */
91
+ google?: GoogleConfig;
92
+ }
93
+
94
+ /** Thin ASC REST client (native fetch + ES256 JWT). */
95
+ export declare class Client {
96
+ constructor(opts: { keyId: string; issuerId: string });
97
+ token: string;
98
+ appId?: string;
99
+ app?: unknown;
100
+ findApp(bundleId: string): Promise<unknown>;
101
+ get(path: string, opts?: { iris?: boolean }): Promise<{ status: number; json: any }>;
102
+ post(path: string, body: unknown): Promise<{ status: number; json: any }>;
103
+ patch(path: string, body: unknown): Promise<{ status: number; json: any }>;
104
+ editVersion(platform: Platform): Promise<any>;
105
+ appInfo(): Promise<any>;
106
+ versionLocalizations(versionId: string): Promise<any[]>;
107
+ localization(id: string, kind?: string): Promise<Record<string, unknown>>;
108
+ }
109
+
110
+ export declare function loadConfig(path?: string): Promise<VydanneConfig & { resolvedLocales: { supported: Record<string, string>; unsupported: string[] } }>;
111
+ export declare function makeToken(opts: { keyId: string; issuerId: string; keyPath?: string }): string;
112
+ export declare function resolveLocales(uiCodes: string[]): { supported: Record<string, string>; unsupported: string[] };
113
+ export declare function toAsc(code: string): string | null;
114
+ export declare const VALID: Set<string>;
115
+ export declare const CONFIG_KEYS: readonly string[];
116
+ export declare const COMMAND_NAMES: readonly CommandName[];
117
+ export declare const COMMANDS: Record<string, { mod: string; client: boolean }>;
118
+
119
+ export default VydanneConfig;
@@ -0,0 +1,56 @@
1
+ // vydanne — one ESM file describes an app for App Store Connect (like zdymak.config.mjs). Copy to
2
+ // `vydanne.config.mjs` and edit. Secrets never live here: ASC key at
3
+ // ~/.appstoreconnect/private_keys/AuthKey_<id>.p8 + ASC_KEY_ID / ASC_ISSUER_ID env; review-contact PII in
4
+ // the gitignored fastlane/metadata/review_information/.
5
+ export default {
6
+ bundleId: "com.example.app",
7
+ primaryLocale: "en-GB", // fallback for every locale without its own listing — MUST be populated
8
+ platforms: ["IOS", "MAC_OS"], // macOS is a SEPARATE platform; its listing text is NOT shared with iOS
9
+
10
+ // asc: { keyId: "…", issuerId: "…" }, // prefer ASC_KEY_ID / ASC_ISSUER_ID env
11
+
12
+ // The app's UI locales. vydanne maps each to its ASC App Store code (de -> de-DE, ar -> ar-SA …) and
13
+ // flags any with no App Store language (e.g. Belarusian `be`) so you never abort an upload on a bad name.
14
+ uiLocales: ["en", "de", "es", "fr", "ja", "zh-Hans"],
15
+ metadataDir: "fastlane/metadata",
16
+
17
+ rating: "4+",
18
+
19
+ // Honest, minimal privacy — "accesses" is not "collects"; E2EE content the developer can't read is not
20
+ // collected. Declare only what actually leaves the device to you.
21
+ privacy: { collected: ["CRASH_DATA", "PERFORMANCE_DATA"], tracking: false },
22
+
23
+ iaps: [
24
+ {
25
+ productId: "com.example.app.unlock",
26
+ type: "non_consumable",
27
+ price: 19.99,
28
+ displayName: "Unlock App", // <= 30 chars (App Store facing)
29
+ description: "Unlock the full app forever. Buy once.", // <= 45 chars
30
+ reviewNote: "Non-consumable buy-once unlock; free to try, then unlocks forever. No account; reviewer can test via StoreKit sandbox.",
31
+ },
32
+ ],
33
+
34
+ // App Preview videos (from zdymak). type per Apple's PreviewType (IPHONE_67 = 6.9", DESKTOP = Mac).
35
+ previews: [
36
+ { platform: "IOS", type: "IPHONE_67", file: "marketing/out/appstore-preview.mp4", poster: "00:00:05:00", locales: ["en-GB", "en-US"] },
37
+ ],
38
+
39
+ export: { encryption: "standard", france: true, appName: "Example", version: "1.0", teamId: "ABCDE12345" },
40
+
41
+ // Google Play (optional). Add this block + set PLAY_JSON_KEY_FILE to the service-account JSON, then run
42
+ // any command with `--store google`. Package-SCOPED: vydanne only ever touches THIS packageName — a
43
+ // shared account key can never mutate another app. The listing lives in <metadataDir>/<play-locale>/ as
44
+ // title.txt (<=30) / short_description.txt (<=80) / full_description.txt (<=4000). Play uses its OWN
45
+ // locale codes (de-DE, es-ES, zh-CN, iw-IL, ar, ur, be … — NOT Apple's), so each folder is named for the
46
+ // Play code. Store graphics come from zdymak, auto-skipped when absent:
47
+ // brand/icons/play/icon-512.png (512 icon) · marketing/out/play-feature-graphic.png (1024x500) ·
48
+ // play-phone-plain/ · play-tablet7-plain/ (7") · play-tablet-plain/ (10"). The AAB binary stays with
49
+ // fastlane; the promo video is a YouTube URL (set in Play Console). `fill --store google` is DRY unless
50
+ // VYDANNE_COMMIT=1.
51
+ google: {
52
+ packageName: "com.example.app",
53
+ metadataDir: "fastlane/metadata/android",
54
+ defaultLocale: "en-GB",
55
+ },
56
+ };