appstore-api-mcp 1.12.0 → 1.15.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/src/cicd.js ADDED
@@ -0,0 +1,481 @@
1
+ // iOS CI/CD → TestFlight bootstrap: template rendering + project auto-detection.
2
+ //
3
+ // Pure logic only (string templating + filesystem reads). The git/gh/ASC side
4
+ // effects live in index.js, which has the shell-out (`runCmd`) and ASC client.
5
+ //
6
+ // The pipeline is the same one shipped by hand in fil-technology/okcircle:
7
+ // two GitHub Actions workflows driving fastlane, Xcode automatic ("cloud")
8
+ // signing via -allowProvisioningUpdates (no `match` repo), and a build number
9
+ // computed as latest-TestFlight-build + 1.
10
+
11
+ import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
12
+ import { join, basename, relative, dirname, sep } from "node:path";
13
+
14
+ // Pinned toolchain — matches the okcircle reference. Bump here in one place.
15
+ const RUNNER = "macos-15";
16
+ const XCODE_PATH = "/Applications/Xcode_16.app";
17
+ const RUBY_VERSION = "3.3";
18
+
19
+ /** Path globs / working-dir derived from where the app lives in the repo. */
20
+ function paths(appDir) {
21
+ const root = appDir === "." || appDir === "";
22
+ return {
23
+ workDir: root ? "." : appDir,
24
+ glob: root ? "**" : `${appDir}/**`,
25
+ gemfile: root ? "Gemfile" : `${appDir}/Gemfile`,
26
+ fastlaneDir: root ? "fastlane" : `${appDir}/fastlane`,
27
+ reportXml: root ? "fastlane/report.xml" : `${appDir}/fastlane/report.xml`,
28
+ };
29
+ }
30
+
31
+ /**
32
+ * Render the seven pipeline files. Returns { "<repo-relative path>": contents }.
33
+ * `vars`: { appDir, projectName, scheme, target, bundleId, teamId }.
34
+ */
35
+ export function renderTemplates(vars) {
36
+ const { appDir, projectName, scheme, target, bundleId, teamId } = vars;
37
+ const p = paths(appDir);
38
+ const project = `${projectName}.xcodeproj`;
39
+ const files = {};
40
+
41
+ files[".github/workflows/ios-ci.yml"] = `name: iOS CI
42
+
43
+ # Fast compile check on every PR / push that touches the app. No secrets and no
44
+ # code signing — it just proves the project still builds.
45
+ on:
46
+ push:
47
+ branches: [main]
48
+ paths:
49
+ - "${p.glob}"
50
+ - ".github/workflows/ios-ci.yml"
51
+ pull_request:
52
+ paths:
53
+ - "${p.glob}"
54
+ - ".github/workflows/ios-ci.yml"
55
+
56
+ concurrency:
57
+ group: ios-ci-\${{ github.ref }}
58
+ cancel-in-progress: true
59
+
60
+ jobs:
61
+ build:
62
+ runs-on: ${RUNNER}
63
+ timeout-minutes: 30
64
+ defaults:
65
+ run:
66
+ working-directory: ${p.workDir}
67
+ steps:
68
+ - uses: actions/checkout@v4
69
+
70
+ - name: Select Xcode
71
+ run: sudo xcode-select -s ${XCODE_PATH}
72
+
73
+ - name: Set up Ruby
74
+ uses: ruby/setup-ruby@v1
75
+ with:
76
+ ruby-version: "${RUBY_VERSION}"
77
+ bundler-cache: true
78
+ working-directory: ${p.workDir}
79
+
80
+ - name: Build for simulator
81
+ run: bundle exec fastlane ci_build
82
+ `;
83
+
84
+ files[".github/workflows/ios-testflight.yml"] = `name: iOS TestFlight
85
+
86
+ # Ships a signed build to TestFlight. Runs on demand (Actions → Run workflow) and
87
+ # automatically when app code lands on main. Requires the ASC_* secrets below.
88
+ on:
89
+ workflow_dispatch:
90
+ push:
91
+ branches: [main]
92
+ paths:
93
+ - "${p.glob}"
94
+ - ".github/workflows/ios-testflight.yml"
95
+
96
+ # Never run two TestFlight uploads at once (build-number collisions).
97
+ concurrency:
98
+ group: ios-testflight
99
+ cancel-in-progress: false
100
+
101
+ jobs:
102
+ testflight:
103
+ runs-on: ${RUNNER}
104
+ timeout-minutes: 45
105
+ defaults:
106
+ run:
107
+ working-directory: ${p.workDir}
108
+ steps:
109
+ - uses: actions/checkout@v4
110
+
111
+ - name: Select Xcode
112
+ run: sudo xcode-select -s ${XCODE_PATH}
113
+
114
+ - name: Set up Ruby
115
+ uses: ruby/setup-ruby@v1
116
+ with:
117
+ ruby-version: "${RUBY_VERSION}"
118
+ bundler-cache: true
119
+ working-directory: ${p.workDir}
120
+
121
+ - name: Build & upload to TestFlight
122
+ run: bundle exec fastlane beta
123
+ env:
124
+ ASC_KEY_ID: \${{ secrets.ASC_KEY_ID }}
125
+ ASC_ISSUER_ID: \${{ secrets.ASC_ISSUER_ID }}
126
+ ASC_KEY_P8: \${{ secrets.ASC_KEY_P8 }}
127
+ # Give xcodebuild's settings probe extra time on cold CI runners.
128
+ FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: "120"
129
+
130
+ - name: Upload build logs on failure
131
+ if: failure()
132
+ uses: actions/upload-artifact@v4
133
+ with:
134
+ name: build-logs
135
+ path: |
136
+ ~/Library/Logs/gym
137
+ ${p.reportXml}
138
+ if-no-files-found: ignore
139
+ `;
140
+
141
+ files[p.gemfile] = `source "https://rubygems.org"
142
+
143
+ gem "fastlane"
144
+ `;
145
+
146
+ files[`${p.fastlaneDir}/Appfile`] = `app_identifier("${bundleId}")
147
+ team_id("${teamId}") # Apple Developer portal team
148
+
149
+ # Authentication is via an App Store Connect API key (see Fastfile / CI secrets),
150
+ # so no apple_id / itc_team is required here.
151
+ `;
152
+
153
+ files[`${p.fastlaneDir}/Fastfile`] = `default_platform(:ios)
154
+
155
+ PROJECT = "${project}"
156
+ SCHEME = "${scheme}"
157
+ TARGET = "${target}"
158
+ BUNDLE_ID = "${bundleId}"
159
+ TEAM_ID = "${teamId}"
160
+
161
+ platform :ios do
162
+ # ---------------------------------------------------------------------------
163
+ # PR / push sanity check: does the app still compile? Builds for the simulator,
164
+ # which needs no code signing and therefore no secrets.
165
+ # ---------------------------------------------------------------------------
166
+ desc "Compile the app for the simulator (no signing / no secrets)"
167
+ lane :ci_build do
168
+ build_app(
169
+ project: PROJECT,
170
+ scheme: SCHEME,
171
+ configuration: "Debug",
172
+ destination: "generic/platform=iOS Simulator",
173
+ skip_archive: true,
174
+ skip_codesigning: true,
175
+ skip_package_ipa: true
176
+ )
177
+ end
178
+
179
+ # ---------------------------------------------------------------------------
180
+ # Build a signed Release archive and ship it to TestFlight.
181
+ #
182
+ # Signing uses Xcode automatic ("cloud") signing driven by the App Store
183
+ # Connect API key (-allowProvisioningUpdates), so there is no certificate or
184
+ # provisioning-profile repo to manage. The same API key authenticates the
185
+ # TestFlight upload.
186
+ #
187
+ # The build number is computed as (latest TestFlight build for this marketing
188
+ # version) + 1, so uploads never collide and no manual pbxproj bump is needed.
189
+ # ---------------------------------------------------------------------------
190
+ desc "Build and upload a new build to TestFlight"
191
+ lane :beta do
192
+ setup_ci # ephemeral keychain when running on CI; no-op locally
193
+
194
+ api_key = app_store_connect_api_key(
195
+ key_id: ENV.fetch("ASC_KEY_ID"),
196
+ issuer_id: ENV.fetch("ASC_ISSUER_ID"),
197
+ key_content: ENV.fetch("ASC_KEY_P8"),
198
+ is_key_content_base64: true,
199
+ in_house: false
200
+ )
201
+
202
+ version = get_version_number(xcodeproj: PROJECT, target: TARGET)
203
+ next_build = latest_testflight_build_number(
204
+ api_key: api_key,
205
+ app_identifier: BUNDLE_ID,
206
+ version: version,
207
+ initial_build_number: 0
208
+ ) + 1
209
+ increment_build_number(build_number: next_build, xcodeproj: PROJECT)
210
+ UI.message("Building #{version} (#{next_build}) for TestFlight")
211
+
212
+ build_app(
213
+ project: PROJECT,
214
+ scheme: SCHEME,
215
+ configuration: "Release",
216
+ export_method: "app-store",
217
+ xcargs: "-allowProvisioningUpdates",
218
+ export_options: {
219
+ signingStyle: "automatic",
220
+ teamID: TEAM_ID
221
+ }
222
+ )
223
+
224
+ upload_to_testflight(
225
+ api_key: api_key,
226
+ app_identifier: BUNDLE_ID,
227
+ skip_waiting_for_build_processing: true,
228
+ distribute_external: false
229
+ )
230
+ end
231
+ end
232
+ `;
233
+
234
+ files[`${p.fastlaneDir}/.gitignore`] = `README.md
235
+ report.xml
236
+ Preview.html
237
+ *.mobileprovision
238
+ *.cer
239
+ *.p12
240
+ *.p8
241
+ test_output/
242
+ `;
243
+
244
+ files[`${p.fastlaneDir}/SETUP.md`] = `# iOS CI / TestFlight
245
+
246
+ Two GitHub Actions workflows live in \`.github/workflows/\`:
247
+
248
+ | Workflow | Trigger | Signing | Secrets |
249
+ |----------------------|------------------------------------------|---------|---------|
250
+ | \`ios-ci.yml\` | PRs + pushes to \`main\` touching the app | none (simulator build) | none |
251
+ | \`ios-testflight.yml\` | manual (**Run workflow**) + push to \`main\` | Xcode automatic / cloud | \`ASC_*\` |
252
+
253
+ Both run on \`${RUNNER}\` and drive [fastlane](https://fastlane.tools) (\`${p.fastlaneDir}/Fastfile\`).
254
+
255
+ ## One-time setup
256
+
257
+ ### 1. App Store Connect API key — create it once at the TEAM level
258
+
259
+ App Store Connect → Users and Access → **Integrations** → App Store Connect API.
260
+ Create a **team** key (not an individual key) with the **App Manager** role, so the
261
+ same three secret values work for *every* app in the account. Download the \`.p8\`
262
+ (you only get one chance) and note the **Key ID** and the team-level **Issuer ID**.
263
+
264
+ ### 2. Add three repository secrets
265
+
266
+ Settings → Secrets and variables → Actions → **New repository secret** (or let
267
+ the \`set_repo_ci_secrets\` MCP tool push them for you):
268
+
269
+ | Secret | Value |
270
+ |-----------------|----------------------------------------------------------|
271
+ | \`ASC_KEY_ID\` | the Key ID (e.g. \`ABC123XYZ\`) |
272
+ | \`ASC_ISSUER_ID\` | the Issuer ID (a UUID) |
273
+ | \`ASC_KEY_P8\` | the \`.p8\` contents, **base64-encoded** (see below) |
274
+
275
+ \`\`\`bash
276
+ base64 -i AuthKey_ABC123XYZ.p8 | pbcopy # paste as ASC_KEY_P8
277
+ \`\`\`
278
+
279
+ ### 3. Prerequisite on the Apple side
280
+
281
+ The app record for \`${bundleId}\` must already exist in App Store Connect. The
282
+ public API cannot create it — create it once in the App Store Connect web UI
283
+ (the bundle ID can be registered with the \`register_bundle_id\` tool first).
284
+
285
+ ## How it works
286
+
287
+ - **Signing:** automatic ("cloud") signing via \`-allowProvisioningUpdates\`,
288
+ authenticated by the API key — Apple manages the distribution certificate and
289
+ provisioning profile, so there is no \`match\` repo or \`.p12\`/profile to maintain.
290
+ - **Build number:** computed as the latest TestFlight build for the current
291
+ marketing version **+ 1**. Manual \`CURRENT_PROJECT_VERSION\` bumps in the
292
+ project are no longer needed for shipping — CI sets a unique number per upload.
293
+ Bump \`MARKETING_VERSION\` (e.g. 1.0 → 1.1) when you want a new version train.
294
+
295
+ ## Run it locally
296
+
297
+ \`\`\`bash
298
+ cd ${p.workDir}
299
+ bundle install
300
+ export ASC_KEY_ID=... ASC_ISSUER_ID=... ASC_KEY_P8="$(base64 -i AuthKey_*.p8)"
301
+ bundle exec fastlane beta
302
+ \`\`\`
303
+
304
+ ## If cloud signing gives you trouble
305
+
306
+ Automatic signing on CI occasionally fails for apps with several capabilities.
307
+ The fallback is [\`fastlane match\`](https://docs.fastlane.tools/actions/match/)
308
+ with a private certificates repo and manual signing — ask and it can be wired in.
309
+ `;
310
+
311
+ return files;
312
+ }
313
+
314
+ // ---- Project auto-detection -------------------------------------------------
315
+
316
+ const SKIP_DIRS = new Set([
317
+ "node_modules", ".git", "Pods", "Carthage", "DerivedData", "build", ".build",
318
+ ]);
319
+
320
+ /** Recursively find .xcodeproj bundles under root (shallow-ish, skips junk). */
321
+ function findXcodeprojs(root, depth = 0, acc = []) {
322
+ if (depth > 4) return acc;
323
+ let entries;
324
+ try {
325
+ entries = readdirSync(root, { withFileTypes: true });
326
+ } catch {
327
+ return acc;
328
+ }
329
+ for (const e of entries) {
330
+ if (!e.isDirectory()) continue;
331
+ if (e.name.endsWith(".xcodeproj")) {
332
+ acc.push(join(root, e.name));
333
+ continue; // don't descend into the project bundle
334
+ }
335
+ if (SKIP_DIRS.has(e.name) || e.name.startsWith(".")) continue;
336
+ findXcodeprojs(join(root, e.name), depth + 1, acc);
337
+ }
338
+ return acc;
339
+ }
340
+
341
+ /** Most frequent value in a list (ties → first seen). */
342
+ function mostCommon(values) {
343
+ const counts = new Map();
344
+ for (const v of values) counts.set(v, (counts.get(v) || 0) + 1);
345
+ let best = null;
346
+ let bestN = 0;
347
+ for (const [v, n] of counts) {
348
+ if (n > bestN) {
349
+ best = v;
350
+ bestN = n;
351
+ }
352
+ }
353
+ return best;
354
+ }
355
+
356
+ function unquote(s) {
357
+ return s.trim().replace(/^["']|["']$/g, "").trim();
358
+ }
359
+
360
+ /**
361
+ * Scan project.pbxproj for bundle ids and the development team.
362
+ * Test targets and build-variable placeholders are filtered out.
363
+ */
364
+ function scanPbxproj(xcodeprojPath) {
365
+ const pbx = join(xcodeprojPath, "project.pbxproj");
366
+ let text = "";
367
+ try {
368
+ text = readFileSync(pbx, "utf8");
369
+ } catch {
370
+ return { bundleId: null, teamId: null, bundleCandidates: [] };
371
+ }
372
+ const bundles = [];
373
+ for (const m of text.matchAll(/PRODUCT_BUNDLE_IDENTIFIER\s*=\s*([^;]+);/g)) {
374
+ const v = unquote(m[1]);
375
+ if (!v || v.includes("$(")) continue; // skip placeholders
376
+ if (/tests?$/i.test(v) || /\.(test|uitest)/i.test(v)) continue; // skip test targets
377
+ bundles.push(v);
378
+ }
379
+ const teams = [];
380
+ for (const m of text.matchAll(/DEVELOPMENT_TEAM\s*=\s*([^;]+);/g)) {
381
+ const v = unquote(m[1]);
382
+ if (v && v !== '""' && !v.includes("$(")) teams.push(v);
383
+ }
384
+ return {
385
+ bundleId: mostCommon(bundles),
386
+ teamId: mostCommon(teams),
387
+ bundleCandidates: [...new Set(bundles)],
388
+ };
389
+ }
390
+
391
+ /**
392
+ * Detect appDir / projectName / scheme / target / bundleId / teamId for a repo.
393
+ * Explicit `provided` values always win. Returns { detected, missing, warnings }.
394
+ * detected: the resolved vars (some may be null if undetectable)
395
+ * missing: names of required fields still unresolved (bundleId / teamId)
396
+ * warnings: human-readable notes (multiple projects, workspace present, …)
397
+ */
398
+ export function detectProject(repoRoot, provided = {}) {
399
+ const warnings = [];
400
+ const projects = findXcodeprojs(repoRoot);
401
+ if (projects.length === 0 && !provided.appDir) {
402
+ return {
403
+ detected: { ...provided },
404
+ missing: ["xcodeproj"],
405
+ warnings: ["No .xcodeproj found under the repo — pass appDir/scheme/bundleId/teamId explicitly."],
406
+ };
407
+ }
408
+
409
+ // Pick a project: prefer the shallowest path (closest to repo root).
410
+ let projectPath = null;
411
+ if (projects.length) {
412
+ projectPath = projects.sort(
413
+ (a, b) => a.split(sep).length - b.split(sep).length,
414
+ )[0];
415
+ if (projects.length > 1)
416
+ warnings.push(
417
+ `Multiple .xcodeproj found; using ${relative(repoRoot, projectPath)}. Override with appDir/scheme if wrong.`,
418
+ );
419
+ }
420
+
421
+ let appDir = provided.appDir;
422
+ let projectName = provided.projectName;
423
+ if (projectPath) {
424
+ const rel = relative(repoRoot, dirname(projectPath)) || ".";
425
+ if (!appDir) appDir = rel === "" ? "." : rel;
426
+ if (!projectName) projectName = basename(projectPath, ".xcodeproj");
427
+ // Warn if a workspace sits alongside (CocoaPods/SPM) — we build the project.
428
+ try {
429
+ const sibling = readdirSync(dirname(projectPath)).find((f) =>
430
+ f.endsWith(".xcworkspace"),
431
+ );
432
+ if (sibling)
433
+ warnings.push(
434
+ `A ${sibling} exists; the Fastfile builds the .xcodeproj directly. If the app needs the workspace (CocoaPods), switch build_app to use \`workspace:\`.`,
435
+ );
436
+ } catch {
437
+ /* ignore */
438
+ }
439
+ }
440
+
441
+ const scanned = projectPath ? scanPbxproj(projectPath) : {};
442
+ const bundleId = provided.bundleId || scanned.bundleId || null;
443
+ const teamId = provided.teamId || scanned.teamId || null;
444
+ if (
445
+ scanned.bundleCandidates &&
446
+ scanned.bundleCandidates.length > 1 &&
447
+ !provided.bundleId
448
+ )
449
+ warnings.push(
450
+ `Multiple bundle ids in the project (${scanned.bundleCandidates.join(", ")}); picked ${bundleId}. Override bundleId if wrong.`,
451
+ );
452
+
453
+ const detected = {
454
+ appDir: appDir || ".",
455
+ projectName: projectName || null,
456
+ scheme: provided.scheme || projectName || null,
457
+ target: provided.target || provided.scheme || projectName || null,
458
+ bundleId,
459
+ teamId,
460
+ };
461
+ const missing = [];
462
+ if (!detected.projectName) missing.push("scheme");
463
+ if (!detected.bundleId) missing.push("bundleId");
464
+ if (!detected.teamId) missing.push("teamId");
465
+ return { detected, missing, warnings };
466
+ }
467
+
468
+ /** True if any pipeline file already exists in the repo (for safe overwrite UX). */
469
+ export function existingPipelineFiles(repoRoot, files) {
470
+ return Object.keys(files).filter((rel) => existsSync(join(repoRoot, rel)));
471
+ }
472
+
473
+ /** Parse owner/name from a git remote URL (ssh or https forms). */
474
+ export function parseRepoSlug(remoteUrl) {
475
+ if (!remoteUrl) return null;
476
+ const u = remoteUrl.trim();
477
+ // git@github.com:owner/name.git or ssh://git@github.com/owner/name.git
478
+ let m = u.match(/[:/]([^/:]+)\/([^/]+?)(?:\.git)?$/);
479
+ if (m) return `${m[1]}/${m[2]}`;
480
+ return null;
481
+ }
package/src/client.js CHANGED
@@ -141,6 +141,25 @@ export class AppStoreConnectClient {
141
141
  return this.request("DELETE", path);
142
142
  }
143
143
 
144
+ /**
145
+ * Like getAll, but also concatenates the `included` side-loaded resources.
146
+ * Returns { data, included }. Needed for endpoints queried with `include=`
147
+ * (e.g. price schedules that side-load their price points).
148
+ */
149
+ async getAllPages(path, query, maxPages = 40) {
150
+ let page = await this.get(path, query);
151
+ const data = Array.isArray(page.data) ? [...page.data] : [];
152
+ const included = Array.isArray(page.included) ? [...page.included] : [];
153
+ let pages = 1;
154
+ while (page.links && page.links.next && pages < maxPages) {
155
+ page = await this.request("GET", page.links.next);
156
+ if (Array.isArray(page.data)) data.push(...page.data);
157
+ if (Array.isArray(page.included)) included.push(...page.included);
158
+ pages++;
159
+ }
160
+ return { data, included };
161
+ }
162
+
144
163
  /** Follow `links.next` and concatenate `data` arrays up to `maxPages`. */
145
164
  async getAll(path, query, maxPages = 20) {
146
165
  let page = await this.get(path, query);
package/src/guardrails.js CHANGED
@@ -28,6 +28,7 @@ export const WRITE_TOOLS = new Set([
28
28
  // catalog / pricing
29
29
  "update_in_app_purchase",
30
30
  "set_app_price",
31
+ "apply_ppp_prices",
31
32
  // provisioning
32
33
  "register_bundle_id",
33
34
  "register_device",
@@ -39,9 +40,29 @@ export const WRITE_TOOLS = new Set([
39
40
  "submit_for_review",
40
41
  "release_version",
41
42
  "set_phased_release",
43
+ // submission flow: build ↔ version ↔ review
44
+ "attach_build_to_version",
45
+ "update_app_store_version",
46
+ "update_build",
47
+ "expire_build",
48
+ "add_review_submission_item",
49
+ "cancel_review_submission",
50
+ "set_beta_build_notes",
51
+ "reorder_screenshots",
52
+ "replace_screenshots",
53
+ "create_subscription_group",
54
+ "create_subscription",
55
+ "create_in_app_purchase",
56
+ "swap_build",
57
+ "release_pipeline",
58
+ "bulk_upsert_localizations",
42
59
  // build & ship (modify project / upload)
43
60
  "bump_build_number",
44
61
  "upload_build",
62
+ // CI/CD bootstrap (writes files / git / GitHub secrets)
63
+ "bootstrap_ios_cicd",
64
+ "set_repo_ci_secrets",
65
+ "bootstrap_testflight",
45
66
  // snapshots
46
67
  "restore_app_metadata",
47
68
  "restore_screenshots",
@@ -50,7 +71,7 @@ export const WRITE_TOOLS = new Set([
50
71
  // High-impact categories with their own opt-out env flags.
51
72
  export const CATEGORY_TOOLS = {
52
73
  RELEASE: new Set(["release_version", "set_phased_release"]),
53
- PRICE_CHANGES: new Set(["set_app_price"]),
74
+ PRICE_CHANGES: new Set(["set_app_price", "apply_ppp_prices"]),
54
75
  REVIEW_REPLIES: new Set(["reply_to_customer_review"]),
55
76
  EXTERNAL_TESTFLIGHT: new Set(["submit_beta_review"]),
56
77
  };