vydanne 0.13.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -105,6 +105,7 @@ committed. A keyId or issuerId found there is refused at load, with a warning te
105
105
  | Source | Use it for |
106
106
  |---|---|
107
107
  | `ASC_KEY_ID` / `ASC_ISSUER_ID` / `PLAY_JSON_KEY_FILE` in the environment | CI secrets, one-off overrides |
108
+ | `ASC_KEY_CONTENT` (the .p8, raw or base64) or `ASC_KEY_PATH` | CI — signs without writing the key to disk |
108
109
  | the `.env` cascade in the repo | one app that needs a different account from the rest |
109
110
  | **the user config file** | **the default for every app you ship** — one account, many repos |
110
111
 
package/SKILL.md CHANGED
@@ -36,7 +36,7 @@ form does **not** exist; set `$env:VAR = "1"` first.
36
36
  **Auth resolves automatically — NEVER put credentials in the config.** That file is committed; vydanne
37
37
  refuses a keyId/issuerId found there and warns. The signing key stays at
38
38
  `~/.appstoreconnect/private_keys/AuthKey_<keyId>.p8`. The ids resolve highest-priority-first from: the
39
- environment (`ASC_KEY_ID` / `ASC_ISSUER_ID` / `PLAY_JSON_KEY_FILE`) → the **`.env` cascade** → the **user
39
+ environment (`ASC_KEY_ID` / `ASC_ISSUER_ID` / `PLAY_JSON_KEY_FILE`, plus `ASC_KEY_CONTENT` or `ASC_KEY_PATH` for the signing key in CI) → the **`.env` cascade** → the **user
40
40
  config file**.
41
41
 
42
42
  The cascade is the standard one (dotenv-parsed, later file wins): `.env` → `.env.<mode>` → `.env.local` →
package/bin/vydanne.mjs CHANGED
@@ -48,11 +48,16 @@ try {
48
48
  row("ASC_KEY_ID", cr.keyId, "ASC_KEY_ID", cr.keyId);
49
49
  row("ASC_ISSUER_ID", cr.issuerId, "ASC_ISSUER_ID");
50
50
  row("PLAY_JSON_KEY_FILE", cr.playJsonKeyFile, "PLAY_JSON_KEY_FILE", cr.playJsonKeyFile);
51
- if (cr.keyId) {
52
- const p = path.join(os.homedir(), ".appstoreconnect", "private_keys", `AuthKey_${cr.keyId}.p8`);
51
+ // The key can now arrive three ways; say WHICH, because "missing" pointing at a path that CI was
52
+ // never going to have is the least useful thing this command could print on a 401.
53
+ if (cr.keyContent) {
54
+ console.log(` \x1b[32m✓\x1b[0m ${"signing key".padEnd(20)} <inline> ${"".padEnd(17)}← ${cr.sources.ASC_KEY_CONTENT}`);
55
+ } else if (cr.keyId) {
56
+ const p = cr.keyPath || path.join(os.homedir(), ".appstoreconnect", "private_keys", `AuthKey_${cr.keyId}.p8`);
53
57
  console.log(existsSync(p)
54
58
  ? ` \x1b[32m✓\x1b[0m ${"signing key".padEnd(20)} ${p}`
55
- : ` \x1b[31m✗\x1b[0m ${"signing key".padEnd(20)} \x1b[31mmissing\x1b[0m — ${p}`);
59
+ : ` \x1b[31m✗\x1b[0m ${"signing key".padEnd(20)} \x1b[31mmissing\x1b[0m — ${p}\n` +
60
+ ` in CI set ASC_KEY_CONTENT (the .p8, raw or base64) instead of writing this file`);
56
61
  }
57
62
  console.log(cr.userFile ? `\nuser config: ${cr.userFile}` : `\nuser config: none found. Looked in:\n${cr.candidates.map((c) => ` ${c}`).join("\n")}`);
58
63
  if (!cr.keyId || !cr.issuerId) {
@@ -88,7 +93,7 @@ try {
88
93
  const spec = COMMANDS[cmd];
89
94
  const { run } = await import(`../src/commands/${spec.mod}.mjs`);
90
95
  const dryRun = Boolean(spec.writes) && !apply;
91
- const client = spec.client ? new Client({ keyId: cfg.keyId, issuerId: cfg.issuerId, dryRun }) : null;
96
+ const client = spec.client ? new Client({ keyId: cfg.keyId, issuerId: cfg.issuerId, keyPath: cfg.keyPath, keyContent: cfg.keyContent, dryRun }) : null;
92
97
  if (dryRun) console.log(yellow(`DRY RUN — '${cmd}' will not change App Store Connect. Add --apply to write.`));
93
98
  // altool authenticates on its own rather than through our JWT, so it needs the raw ids.
94
99
  const ok = await run(cfg, client, spec.credentials ? { keyId: cfg.keyId, issuerId: cfg.issuerId } : undefined);
@@ -153,6 +158,8 @@ usage: vydanne <command> [--apply] [--config vydanne.config.mjs]
153
158
  locales UI -> ASC locale mapping + unsupported
154
159
  auth which credentials resolved, and from where (masked) — run this on a 401
155
160
  credentials: env > .env cascade (.env, .env.<mode>, .env.local, .env.<mode>.local) > user config
161
+ CI: ASC_KEY_CONTENT carries the .p8 itself (raw or base64) so no key is written to the runner disk;
162
+ ASC_KEY_PATH points at one. Without either, the key is read from ~/.appstoreconnect/private_keys.
156
163
  (\$VYDANNE_CONFIG_HOME, %APPDATA%\\vydanne or \$XDG_CONFIG_HOME/vydanne, ~/.appstoreconnect).
157
164
  NEVER the committed vydanne.config.mjs — run \`vydanne auth\` to see what resolved.
158
165
  toggles: VYDANNE_SKIP_METADATA / VYDANNE_SKIP_SCREENSHOTS (fill), VYDANNE_REPLACE=1 (fill/previews:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vydanne",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
4
4
  "description": "App Store Connect + Google Play submission prep — the companion to zdymak (media). Native Node (no fastlane/Ruby/Python): localized listings, screenshot/preview/icon upload, ratings, review contact, accessibility & privacy labels, IAP, export docs, build upload to TestFlight / a Play closed track, a diff of local-vs-store, and a preflight verifier that encodes the store gotchas. Never submits for review. iOS/macOS via the ASC REST API; Android via the Play Developer Edits API (--store google).",
5
5
  "keywords": [
6
6
  "app-store-connect",
package/src/client.mjs CHANGED
@@ -21,8 +21,8 @@ const MUTATING = new Set(["POST", "PATCH", "PUT", "DELETE"]);
21
21
  // fetched from the FULL list (get_edit filters out READY_FOR_REVIEW), and individual localization reads
22
22
  // (list endpoints return sparse/empty text).
23
23
  export class Client {
24
- constructor({ keyId, issuerId, dryRun = false }) {
25
- this.token = makeToken({ keyId, issuerId });
24
+ constructor({ keyId, issuerId, keyPath, keyContent, dryRun = false }) {
25
+ this.token = makeToken({ keyId, issuerId, keyPath, keyContent });
26
26
  /** No mutating request leaves this process. Set by bin/ for a write command without `--apply`. */
27
27
  this.dryRun = dryRun;
28
28
  /** What a real run WOULD have sent, in order — the dry-run report, and the count bin/ prints. */
@@ -54,10 +54,19 @@ async function versionsFor(client, platform) {
54
54
  return json.data || [];
55
55
  }
56
56
 
57
- /** The newest build, together with the marketing version its archive declares. */
58
- async function newestBuildWithVersion(client) {
57
+ /**
58
+ * The newest build FOR THIS PLATFORM, together with the marketing version its archive declares.
59
+ *
60
+ * Filtered by platform, which it was not: an app shipping on iOS and the Mac App Store uploads a build
61
+ * to each, and the unfiltered query returned whichever went up last. Preparing MAC_OS then tried to
62
+ * attach an iOS build and Apple answered 409 — so the Mac version was left holding nothing, or an older
63
+ * build, purely because the iOS one was newer. The 409 made it visible; a same-platform mix-up would
64
+ * not have been.
65
+ */
66
+ async function newestBuildWithVersion(client, platform) {
59
67
  const { json } = await client.get(
60
68
  `/v1/builds?filter[app]=${client.appId}&sort=-uploadedDate&limit=1` +
69
+ (platform ? `&filter[preReleaseVersion.platform]=${platform}` : "") +
61
70
  `&fields[builds]=version,processingState&include=preReleaseVersion` +
62
71
  `&fields[preReleaseVersions]=version`,
63
72
  );
@@ -218,7 +227,7 @@ export async function run(config, client) {
218
227
  async function prepareOne(config, client, platform) {
219
228
  console.log(green(`prepare → App Store version (${platform})`));
220
229
 
221
- const { build, marketing } = await newestBuildWithVersion(client);
230
+ const { build, marketing } = await newestBuildWithVersion(client, platform);
222
231
  const target = process.env.VYDANNE_VERSION || marketing;
223
232
 
224
233
  if (!target) {
@@ -56,7 +56,12 @@ export async function run(config, client) {
56
56
  console.log(" no versions on this app");
57
57
  return true;
58
58
  }
59
- const version = mine[0];
59
+ // Pick the version that is ACTUALLY in review, not merely the first one Apple returned. An app on two
60
+ // platforms has two 1.2 records, and `mine[0]` was whichever came back first: with iOS
61
+ // WAITING_FOR_REVIEW and macOS PREPARE_FOR_SUBMISSION, this reported "not submitted — nothing to
62
+ // withdraw" and exited 0, never looking at the submission it was asked to cancel. Silence is the worst
63
+ // possible answer here, because the operator walks away believing the withdrawal happened.
64
+ const version = mine.find((v) => WITHDRAWABLE[v.attributes.appStoreState]) || mine[0];
60
65
  const state = version.attributes.appStoreState;
61
66
  const label = `${version.attributes.versionString} (${state})`;
62
67
 
package/src/config.mjs CHANGED
@@ -50,6 +50,7 @@ export async function loadConfig(p) {
50
50
  primaryLocale: need("primaryLocale"),
51
51
  keyId: creds.keyId,
52
52
  issuerId: creds.issuerId,
53
+ keyPath: creds.keyPath,
53
54
  uiLocales: raw.uiLocales || [],
54
55
  // App code -> App Store locale, for codes Apple spells differently or does not know yet. Merged over
55
56
  // the built-in table rather than replacing it, so an app declares only its exceptions.
@@ -137,5 +138,8 @@ export async function loadConfig(p) {
137
138
  : null,
138
139
  };
139
140
  c.resolvedLocales = resolveLocales(c.uiLocales, c.localeMap);
141
+ // The signing key rides along NON-ENUMERABLE, like it does on the credentials object: a config is
142
+ // exactly the kind of thing someone dumps while debugging, and a private key must not be in that dump.
143
+ Object.defineProperty(c, "keyContent", { value: creds.keyContent, enumerable: false, writable: false });
140
144
  return c;
141
145
  }
@@ -152,13 +152,25 @@ export function resolveCredentials(raw = {}, cwd = process.cwd(), env = process.
152
152
  return undefined;
153
153
  };
154
154
 
155
- return {
155
+ // The signing key itself, for CI. A developer machine keeps the .p8 in ~/.appstoreconnect and needs
156
+ // neither of these; a runner has only environment variables, and without them every workflow had to
157
+ // write the key to disk itself just to satisfy vydanne's path convention.
158
+ //
159
+ // NON-ENUMERABLE, deliberately: a private key must not be one careless `console.log(creds)` or
160
+ // `JSON.stringify(config)` away from a CI log that the whole team can read. Explicit property access
161
+ // still works, which is the only way it is ever read; a spread or a dump cannot see it.
162
+ const keyContent = pick("ASC_KEY_CONTENT", "keyContent");
163
+
164
+ const out = {
156
165
  keyId: pick("ASC_KEY_ID", "keyId"),
157
166
  issuerId: pick("ASC_ISSUER_ID", "issuerId"),
167
+ keyPath: expandHome(pick("ASC_KEY_PATH", "keyPath"), home),
158
168
  playJsonKeyFile: expandHome(pick("PLAY_JSON_KEY_FILE", "playJsonKeyFile"), home),
159
169
  sources,
160
170
  warnings,
161
171
  userFile,
162
172
  candidates: configCandidates(env, home),
163
173
  };
174
+ Object.defineProperty(out, "keyContent", { value: keyContent, enumerable: false, writable: false });
175
+ return out;
164
176
  }
package/src/index.mjs CHANGED
@@ -13,7 +13,7 @@ export { PlayClient } from "./play/client.mjs";
13
13
  export { loadConfig, CONFIG_KEYS } from "./config.mjs";
14
14
  export { COMMANDS, PLAY_COMMANDS, COMMAND_NAMES } from "./registry.mjs";
15
15
  export { resolveLocales, toAsc, VALID, UI_TO_ASC } from "./locales.mjs";
16
- export { makeToken } from "./jwt.mjs";
16
+ export { makeToken, resolveKey } from "./jwt.mjs";
17
17
  export { DEFAULT_SCREENSHOT_BASE, IOS_DEVICE, MAC_DEVICE, screenshotBase } from "./screenshots.mjs";
18
18
  export { DEFAULT_PLAY_IMAGES, PLAY_IMAGE_KIND, playImages } from "./play/images.mjs";
19
19
 
@@ -55,7 +55,7 @@ export async function runCommand(name, opts = {}) {
55
55
  return { ok: (await run(config, client)) !== false, planned: [] };
56
56
  }
57
57
 
58
- const client = spec.client ? new Client({ keyId: config.keyId, issuerId: config.issuerId, dryRun }) : null;
58
+ const client = spec.client ? new Client({ keyId: config.keyId, issuerId: config.issuerId, keyPath: config.keyPath, keyContent: config.keyContent, dryRun }) : null;
59
59
  const { run } = await import(`./commands/${spec.mod}.mjs`);
60
60
  // altool authenticates on its own rather than through our JWT, so it needs the raw ids.
61
61
  const ok = await run(config, client, spec.credentials ? { keyId: config.keyId, issuerId: config.issuerId } : undefined);
package/src/jwt.mjs CHANGED
@@ -3,12 +3,51 @@ import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
 
6
+ const PEM_MARKER = "-----BEGIN";
7
+
8
+ /**
9
+ * The .p8 itself, from whichever place this machine keeps it.
10
+ *
11
+ * A FILE is the right default on a developer's Mac, where the key sits beside the credentials in
12
+ * `~/.appstoreconnect`. It is the wrong and only option in CI, where a secret arrives as an
13
+ * environment variable and there is no home directory worth writing to — every caller was left
14
+ * materialising the key itself:
15
+ *
16
+ * mkdir -p "$HOME/.appstoreconnect/private_keys"
17
+ * printf '%s' "$ASC_KEY_CONTENT" | base64 --decode > ".../AuthKey_${ASC_KEY_ID}.p8"
18
+ *
19
+ * That is four lines of shell, per workflow, that must agree with vydanne's private path convention —
20
+ * and it silently writes a signing key to disk on a shared runner. `keyContent` removes the need.
21
+ *
22
+ * Accepts the PEM as-is or base64-encoded, because CI secrets are usually stored base64 (GitHub's own
23
+ * docs recommend it for multi-line values, and `ASC_KEY_CONTENT` is already base64 wherever fastlane
24
+ * reads it with `is_key_content_base64`).
25
+ */
26
+ export function resolveKey({ keyId, keyPath, keyContent }) {
27
+ if (keyContent) {
28
+ const raw = keyContent.trim();
29
+ if (raw.includes(PEM_MARKER)) return raw;
30
+ const decoded = Buffer.from(raw, "base64").toString("utf8");
31
+ if (decoded.includes(PEM_MARKER)) return decoded;
32
+ throw new Error(
33
+ "vydanne: ASC_KEY_CONTENT is neither a PEM nor base64 of one — expected it to contain " +
34
+ `"${PEM_MARKER} PRIVATE KEY". Check the secret was stored whole, newlines included.`,
35
+ );
36
+ }
37
+ const file = keyPath || path.join(os.homedir(), ".appstoreconnect", "private_keys", `AuthKey_${keyId}.p8`);
38
+ if (!fs.existsSync(file)) {
39
+ throw new Error(
40
+ `vydanne: ASC key not found at ${file}. On a developer machine put the .p8 there; in CI set ` +
41
+ "ASC_KEY_CONTENT (the .p8, raw or base64) or ASC_KEY_PATH instead — no file needed.",
42
+ );
43
+ }
44
+ return fs.readFileSync(file, "utf8");
45
+ }
46
+
6
47
  // App Store Connect JWT (ES256). Node's built-in crypto signs it — `dsaEncoding: "ieee-p1363"` returns the
7
48
  // 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");
49
+ export function makeToken({ keyId, issuerId, keyPath, keyContent }) {
50
+ const p8 = resolveKey({ keyId, keyPath, keyContent });
12
51
  const b64u = (o) => Buffer.from(typeof o === "string" ? o : JSON.stringify(o)).toString("base64url");
13
52
  const now = Math.floor(Date.now() / 1000);
14
53
  const input = `${b64u({ alg: "ES256", kid: keyId, typ: "JWT" })}.${b64u({ iss: issuerId, iat: now, exp: now + 1200, aud: "appstoreconnect-v1" })}`;
package/types/index.d.ts CHANGED
@@ -359,7 +359,7 @@ export interface VydanneConfig {
359
359
 
360
360
  /** Thin ASC REST client (native fetch + ES256 JWT). */
361
361
  export declare class Client {
362
- constructor(opts: { keyId: string; issuerId: string; dryRun?: boolean });
362
+ constructor(opts: { keyId: string; issuerId: string; keyPath?: string; keyContent?: string; dryRun?: boolean });
363
363
  token: string;
364
364
  appId?: string;
365
365
  app?: unknown;
@@ -420,7 +420,22 @@ export declare function runCommand(
420
420
  ): Promise<{ ok: boolean; planned: Array<{ method: string; path: string; attributes: Record<string, unknown> }> }>;
421
421
 
422
422
  export declare function loadConfig(path?: string): Promise<ResolvedConfig>;
423
- export declare function makeToken(opts: { keyId: string; issuerId: string; keyPath?: string }): string;
423
+ /**
424
+ * App Store Connect JWT (ES256).
425
+ *
426
+ * The signing key comes from ONE of three places, in order: `keyContent` (the .p8 itself, raw PEM or
427
+ * base64 — what CI hands over, so no key is written to a runner's disk), `keyPath`, or the default
428
+ * `~/.appstoreconnect/private_keys/AuthKey_<keyId>.p8`.
429
+ */
430
+ export declare function makeToken(opts: {
431
+ keyId: string;
432
+ issuerId: string;
433
+ keyPath?: string;
434
+ keyContent?: string;
435
+ }): string;
436
+
437
+ /** The .p8 text itself, from `keyContent` / `keyPath` / the default location. Throws if it is neither. */
438
+ export declare function resolveKey(opts: { keyId?: string; keyPath?: string; keyContent?: string }): string;
424
439
  export declare function resolveLocales(uiCodes: string[], extra?: Record<string, string>): ResolvedLocales;
425
440
  export declare function toAsc(code: string, extra?: Record<string, string>): string | null;
426
441
  export declare const VALID: Set<string>;