ソースを参照

Add App Store Connect folder with listing metadata and API sync.

Introduces app-connect for Mac App Store copy, xcconfig wiring, and a script
to push metadata to App Store Connect, plus a complete App Store description.

Co-authored-by: Cursor <cursoragent@cursor.com>
Uzair Tahir 2 ヶ月 前
コミット
a15be31d1a

+ 7 - 0
.gitignore

@@ -15,4 +15,11 @@ xcuserdata/
 # Secrets
 .env
 .env.*
+!.env.example
 *.p8
+
+# App Store Connect (app-connect/)
+app-connect/.env
+!app-connect/.env.example
+app-connect/Local.xcconfig
+app-connect/AuthKey_*.p8

+ 18 - 2
App for Indeed.xcodeproj/project.pbxproj

@@ -8,6 +8,7 @@
 
 /* Begin PBXFileReference section */
 		27D852802FB1D367008DF557 /* App for Indeed.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "App for Indeed.app"; sourceTree = BUILT_PRODUCTS_DIR; };
+		27D852912FB1D369008DF557 /* AppConnect.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppConnect.xcconfig; sourceTree = "<group>"; };
 /* End PBXFileReference section */
 
 /* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -33,10 +34,19 @@
 			isa = PBXGroup;
 			children = (
 				27D852822FB1D367008DF557 /* App for Indeed */,
+				27D852922FB1D369008DF557 /* app-connect */,
 				27D852812FB1D367008DF557 /* Products */,
 			);
 			sourceTree = "<group>";
 		};
+		27D852922FB1D369008DF557 /* app-connect */ = {
+			isa = PBXGroup;
+			children = (
+				27D852912FB1D369008DF557 /* AppConnect.xcconfig */,
+			);
+			path = "app-connect";
+			sourceTree = "<group>";
+		};
 		27D852812FB1D367008DF557 /* Products */ = {
 			isa = PBXGroup;
 			children = (
@@ -245,6 +255,7 @@
 		};
 		27D8528F2FB1D368008DF557 /* Debug */ = {
 			isa = XCBuildConfiguration;
+			baseConfigurationReference = 27D852912FB1D369008DF557 /* AppConnect.xcconfig */;
 			buildSettings = {
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
@@ -252,6 +263,7 @@
 				CODE_SIGN_STYLE = Automatic;
 				COMBINE_HIDPI_IMAGES = YES;
 				CURRENT_PROJECT_VERSION = 1;
+				DEVELOPMENT_TEAM = NNC7V99779;
 				ENABLE_APP_SANDBOX = YES;
 				ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
 				ENABLE_USER_SELECTED_FILES = readonly;
@@ -260,6 +272,7 @@
 				INFOPLIST_KEY_AppStoreDeveloperID = "$(APP_STORE_DEVELOPER_ID)";
 				INFOPLIST_KEY_CFBundleDisplayName = "App for Indeed";
 				INFOPLIST_KEY_CFBundleName = "App for Indeed";
+				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
@@ -268,7 +281,7 @@
 					"@executable_path/../Frameworks",
 				);
 				MARKETING_VERSION = 1.0;
-				PRODUCT_BUNDLE_IDENTIFIER = "MQL-DEV.App-for-Indeed";
+				PRODUCT_BUNDLE_IDENTIFIER = "com.hwaccount.app-for-indeed";
 				PRODUCT_NAME = "App for Indeed";
 				REGISTER_APP_GROUPS = YES;
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -282,6 +295,7 @@
 		};
 		27D852902FB1D368008DF557 /* Release */ = {
 			isa = XCBuildConfiguration;
+			baseConfigurationReference = 27D852912FB1D369008DF557 /* AppConnect.xcconfig */;
 			buildSettings = {
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
@@ -289,6 +303,7 @@
 				CODE_SIGN_STYLE = Automatic;
 				COMBINE_HIDPI_IMAGES = YES;
 				CURRENT_PROJECT_VERSION = 1;
+				DEVELOPMENT_TEAM = NNC7V99779;
 				ENABLE_APP_SANDBOX = YES;
 				ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
 				ENABLE_USER_SELECTED_FILES = readonly;
@@ -297,6 +312,7 @@
 				INFOPLIST_KEY_AppStoreDeveloperID = "$(APP_STORE_DEVELOPER_ID)";
 				INFOPLIST_KEY_CFBundleDisplayName = "App for Indeed";
 				INFOPLIST_KEY_CFBundleName = "App for Indeed";
+				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
@@ -305,7 +321,7 @@
 					"@executable_path/../Frameworks",
 				);
 				MARKETING_VERSION = 1.0;
-				PRODUCT_BUNDLE_IDENTIFIER = "MQL-DEV.App-for-Indeed";
+				PRODUCT_BUNDLE_IDENTIFIER = "com.hwaccount.app-for-indeed";
 				PRODUCT_NAME = "App for Indeed";
 				REGISTER_APP_GROUPS = YES;
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;

+ 1 - 1
App for Indeed.xcodeproj/xcshareddata/xcschemes/App for Indeed.xcscheme

@@ -1,7 +1,7 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <Scheme
    LastUpgradeVersion = "2640"
-   version = "1.7">
+   version = "1.8">
    <BuildAction
       parallelizeBuildables = "YES"
       buildImplicitDependencies = "YES"

+ 18 - 0
app-connect/.env.example

@@ -0,0 +1,18 @@
+# App Store Connect API credentials
+# Create at: https://appstoreconnect.apple.com → Users and Access → Integrations → App Store Connect API
+#
+# Copy this file to `.env` and fill in your values:
+#   cp .env.example .env
+
+APP_STORE_CONNECT_ISSUER_ID=
+APP_STORE_CONNECT_KEY_ID=
+# Path to the downloaded .p8 key (relative to this folder is fine)
+APP_STORE_CONNECT_PRIVATE_KEY_PATH=./AuthKey_XXXXXXXXXX.p8
+
+# App identifiers (App Store Connect → App → App Information)
+APP_STORE_CONNECT_BUNDLE_ID=com.hwaccount.app-for-indeed
+APP_STORE_CONNECT_TEAM_ID=NNC7V99779
+
+# Numeric IDs for Mac App Store marketing links (also used in Xcode Local.xcconfig)
+APP_STORE_APP_ID=
+APP_STORE_DEVELOPER_ID=

+ 7 - 0
app-connect/AppConnect.xcconfig

@@ -0,0 +1,7 @@
+// Shared App Store Connect / Mac App Store build settings.
+// Per-developer values live in Local.xcconfig (gitignored).
+
+#include? "Local.xcconfig"
+
+APP_STORE_APP_ID =
+APP_STORE_DEVELOPER_ID =

+ 7 - 0
app-connect/Local.xcconfig.example

@@ -0,0 +1,7 @@
+// Copy to Local.xcconfig and set your App Store numeric IDs.
+// Used by the Xcode target for INFOPLIST_KEY_AppStoreAppID / AppStoreDeveloperID.
+//
+//   cp Local.xcconfig.example Local.xcconfig
+
+APP_STORE_APP_ID =
+APP_STORE_DEVELOPER_ID =

+ 64 - 0
app-connect/README.md

@@ -0,0 +1,64 @@
+# App Store Connect
+
+Configuration for connecting to [App Store Connect](https://appstoreconnect.apple.com) for this app (**App for Indeed**).
+
+## Setup
+
+1. **API key** (for automation, CI, or CLI tools):
+   - In App Store Connect: **Users and Access → Integrations → App Store Connect API → Generate API Key**.
+   - Download the `.p8` file into this folder (e.g. `AuthKey_XXXXXXXXXX.p8`). Do not commit it.
+
+2. **Environment file**:
+   ```bash
+   cd app-connect
+   cp .env.example .env
+   ```
+   Edit `.env` with your Issuer ID, Key ID, path to the `.p8` file, and numeric App Store / Developer IDs.
+
+3. **Xcode build settings** (Mac App Store share links in the app):
+   ```bash
+   cp Local.xcconfig.example Local.xcconfig
+   ```
+   Set `APP_STORE_APP_ID` and `APP_STORE_DEVELOPER_ID` in `Local.xcconfig`. The Xcode target includes `AppConnect.xcconfig`, which pulls in `Local.xcconfig`.
+
+## Variables
+
+| Variable | Purpose |
+|----------|---------|
+| `APP_STORE_CONNECT_ISSUER_ID` | API Issuer ID from App Store Connect |
+| `APP_STORE_CONNECT_KEY_ID` | API Key ID |
+| `APP_STORE_CONNECT_PRIVATE_KEY_PATH` | Path to the `.p8` private key |
+| `APP_STORE_CONNECT_BUNDLE_ID` | Bundle ID (`com.hwaccount.app-for-indeed`) |
+| `APP_STORE_CONNECT_TEAM_ID` | Apple Developer Team ID |
+| `APP_STORE_APP_ID` | Numeric Mac App Store app ID |
+| `APP_STORE_DEVELOPER_ID` | Numeric developer ID for “More Apps” link |
+
+## App Store listing copy
+
+Ready-to-paste metadata lives in [`app-data/`](app-data/) (name, subtitle, description, promotional text, copyright, keywords, and support/legal/marketing URLs).
+
+### Push metadata to App Store Connect
+
+After filling in `.env` and editing `app-data/*.txt`:
+
+```bash
+cd app-connect
+python3 scripts/update_app_store_metadata.py
+```
+
+- **App Information** (macOS): name, subtitle, privacy policy URL  
+- **Version 1.0** (default): copyright, description, keywords, promotional text, marketing URL, support URL  
+
+Override version or locale: `APP_STORE_VERSION=1.0 APP_STORE_LOCALE=en-US python3 scripts/update_app_store_metadata.py`
+
+Custom Terms of Use (EULA URL) must still use Apple’s standard EULA or be set manually in App Store Connect if you need a custom license agreement.
+
+## Loading `.env` in the shell
+
+```bash
+set -a
+source app-connect/.env
+set +a
+```
+
+Tools such as Fastlane, `xcrun altool`, and custom scripts can read the same variable names.

+ 22 - 0
app-connect/app-data/README.md

@@ -0,0 +1,22 @@
+# App Store Connect — App metadata
+
+Copy-ready text for **App for Indeed** (macOS). Each file maps to a field in App Store Connect → your app → **App Information** or version **Metadata**.
+
+| File | App Store Connect field | Limit |
+|------|-------------------------|-------|
+| `app-name.txt` | Name | 30 characters |
+| `subtitle.txt` | Subtitle | 30 characters |
+| `promotional-text.txt` | Promotional Text | 170 characters |
+| `description.txt` | Description | 4,000 characters |
+| `copyright.txt` | Copyright | — |
+| `keywords.txt` | Keywords | 100 characters total |
+| `link-marketing.txt` | Marketing URL | — |
+| `link-support.txt` | Support URL | — |
+| `link-privacy.txt` | Privacy Policy URL | — |
+| `link-terms.txt` | License Agreement / Terms URL (EULA) | — |
+
+URLs match `AppLegalURLs.swift` and `Paywall.storekit` in the Xcode project.
+
+**Contact email** (not a separate URL field): support@appforindeed.com — listed on the marketing site contact page.
+
+Before submitting, re-check character limits in App Store Connect; trailing newlines in these files are not part of the copy.

+ 1 - 0
app-connect/app-data/app-name.txt

@@ -0,0 +1 @@
+App for Indeed

+ 1 - 0
app-connect/app-data/copyright.txt

@@ -0,0 +1 @@
+© 2026 App for Indeed

+ 81 - 0
app-connect/app-data/description.txt

@@ -0,0 +1,81 @@
+App for Indeed is a native macOS companion for focused job hunting. Search with AI, save listings, build résumés, manage career profiles, and browse Indeed—all in one workspace instead of juggling browser tabs.
+
+BUILT FOR MAC JOB SEEKERS
+
+Whether you are exploring new roles, targeting a specific company, or sharpening your CV between interviews, App for Indeed keeps your search organized on the desktop you already use every day.
+
+AI JOB SEARCH ON HOME
+
+• Chat-style search: describe the job you want in plain language and get concise role summaries.
+• Smart shortcuts: tap Role, Company, or Skill to start focused searches faster.
+• Continue the conversation: refine results with follow-up prompts without starting over.
+• Act on results: save promising listings or open them on Indeed to read details and apply.
+
+EMBEDDED INDEED BROWSER
+
+• View job pages and application flows inside the app.
+• Navigate with back, forward, and reload, then return to your dashboard.
+• Stay in context while moving between search, saved jobs, and apply steps.
+
+SAVED JOBS WORKSPACE
+
+• Save opportunities from Home search results.
+• Revisit saved listings anytime to compare, apply, or follow up.
+• Keep shortlists on your Mac without losing track in browser history.
+
+CV MAKER
+
+• Browse design- and profession-based résumé templates.
+• Filter templates by style and preview layouts before you commit.
+• Connect a template to a saved profile to generate a filled CV.
+• Export a polished PDF when you are ready to share or apply.
+
+PROFILE MANAGEMENT
+
+• Create multiple job-seeker profiles on your Mac.
+• Store contact details, experience, education, and skills in one place.
+• Reuse profiles across CV previews and exports for different applications.
+
+SETTINGS & APPEARANCE
+
+• Light, dark, or system appearance.
+• Quick access to support, privacy policy, terms of use, and marketing site.
+• Share the app with friends via the built-in macOS share menu.
+
+APP FOR INDEED PRO
+
+Go further with auto-renewing weekly, monthly, or yearly subscriptions billed securely through your Apple ID:
+
+• Unlimited AI job search on Home (free access includes a limited number of searches).
+• Save jobs and open listings in the embedded Indeed browser.
+• Full CV Maker access, multiple profiles, and PDF export.
+• Role, company, and skill shortcuts on Home.
+
+Manage or cancel subscriptions anytime in System Settings → Apple ID → Subscriptions. Payment is charged to your Apple ID at confirmation. Subscriptions renew automatically unless canceled at least 24 hours before the end of the current period. Refunds are handled by Apple according to Apple’s policies.
+
+FREE TIER
+
+Try core Home AI search before upgrading. Free users can send a limited number of job-search messages; Pro unlocks unlimited searches and premium tools.
+
+PRIVACY & DATA
+
+• Saved profiles, jobs, and preferences are stored locally on your Mac.
+• AI features send the text you enter to a third-party AI provider to generate responses.
+• Purchases and subscriptions are processed by Apple; we do not receive your full payment card details.
+
+Privacy Policy: https://sites.google.com/view/app-for-indeed/privacy-policy
+Terms of Use: https://sites.google.com/view/app-for-indeed/terms-of-services
+
+REQUIREMENTS
+
+• macOS 12.0 or later.
+• Internet connection for AI search, template loading, and embedded Indeed browsing.
+
+SUPPORT
+
+Visit Settings → Support or our help page: https://sites.google.com/view/app-for-indeed/support
+Email: support@appforindeed.com
+
+DISCLAIMER
+
+App for Indeed is an independent productivity app. It is not affiliated with, sponsored by, or endorsed by Indeed, Inc. Indeed is a trademark of Indeed, Inc.

+ 1 - 0
app-connect/app-data/keywords.txt

@@ -0,0 +1 @@
+indeed,jobsearch,resume,cv,career,ai,mac,employment,recruitment,hiring,savedjobs,resumebuilder

+ 1 - 0
app-connect/app-data/link-marketing.txt

@@ -0,0 +1 @@
+https://sites.google.com/view/app-for-indeed/home

+ 1 - 0
app-connect/app-data/link-privacy.txt

@@ -0,0 +1 @@
+https://sites.google.com/view/app-for-indeed/privacy-policy

+ 1 - 0
app-connect/app-data/link-support.txt

@@ -0,0 +1 @@
+https://sites.google.com/view/app-for-indeed/support

+ 1 - 0
app-connect/app-data/link-terms.txt

@@ -0,0 +1 @@
+https://sites.google.com/view/app-for-indeed/terms-of-services

+ 1 - 0
app-connect/app-data/promotional-text.txt

@@ -0,0 +1 @@
+Search roles with AI, save jobs, build CVs, and browse Indeed in one Mac app. Upgrade to Pro for unlimited AI search, profiles, and PDF export.

+ 1 - 0
app-connect/app-data/subtitle.txt

@@ -0,0 +1 @@
+AI Job Search & CV Maker

+ 251 - 0
app-connect/scripts/update_app_store_metadata.py

@@ -0,0 +1,251 @@
+#!/usr/bin/env python3
+"""Push app-data/*.txt to App Store Connect via the official API."""
+
+from __future__ import annotations
+
+import json
+import os
+import sys
+import time
+from pathlib import Path
+
+import jwt
+import requests
+
+API_BASE = "https://api.appstoreconnect.apple.com/v1"
+ROOT = Path(__file__).resolve().parents[1]
+APP_DATA = ROOT / "app-data"
+ENV_PATH = ROOT / ".env"
+VERSION_STRING = os.environ.get("APP_STORE_VERSION", "1.0")
+LOCALE = os.environ.get("APP_STORE_LOCALE", "en-US")
+
+
+def load_env(path: Path) -> dict[str, str]:
+    values: dict[str, str] = {}
+    for line in path.read_text(encoding="utf-8").splitlines():
+        line = line.strip()
+        if not line or line.startswith("#") or "=" not in line:
+            continue
+        key, value = line.split("=", 1)
+        values[key.strip()] = value.strip()
+    return values
+
+
+def read_field(name: str) -> str:
+    path = APP_DATA / name
+    if not path.exists():
+        raise FileNotFoundError(path)
+    return path.read_text(encoding="utf-8").strip()
+
+
+class ASCClient:
+    def __init__(self, issuer_id: str, key_id: str, private_key_path: str):
+        self.issuer_id = issuer_id
+        self.key_id = key_id
+        self.private_key = Path(private_key_path).expanduser().read_text(encoding="utf-8")
+        self.session = requests.Session()
+        self._token: str | None = None
+        self._token_exp = 0.0
+
+    def _ensure_token(self) -> str:
+        now = time.time()
+        if self._token and now < self._token_exp - 60:
+            return self._token
+        issued_at = int(now)
+        payload = {
+            "iss": self.issuer_id,
+            "iat": issued_at,
+            "exp": issued_at + 1200,
+            "aud": "appstoreconnect-v1",
+        }
+        self._token = jwt.encode(
+            payload,
+            self.private_key,
+            algorithm="ES256",
+            headers={"kid": self.key_id, "typ": "JWT"},
+        )
+        self._token_exp = issued_at + 1200
+        return self._token
+
+    def request(self, method: str, path: str, **kwargs) -> requests.Response:
+        token = self._ensure_token()
+        headers = kwargs.pop("headers", {})
+        headers["Authorization"] = f"Bearer {token}"
+        headers.setdefault("Content-Type", "application/json")
+        url = path if path.startswith("http") else f"{API_BASE}{path}"
+        response = self.session.request(method, url, headers=headers, timeout=60, **kwargs)
+        return response
+
+    def get_json(self, path: str, params: dict | None = None) -> dict:
+        response = self.request("GET", path, params=params)
+        if not response.ok:
+            raise RuntimeError(f"GET {path} failed ({response.status_code}): {response.text}")
+        return response.json()
+
+    def patch(self, resource_type: str, resource_id: str, attributes: dict) -> dict:
+        body = {
+            "data": {
+                "type": resource_type,
+                "id": resource_id,
+                "attributes": attributes,
+            }
+        }
+        response = self.request("PATCH", f"/{resource_type}/{resource_id}", json=body)
+        if not response.ok:
+            raise RuntimeError(
+                f"PATCH {resource_type}/{resource_id} failed ({response.status_code}): {response.text}"
+            )
+        return response.json()
+
+
+def find_app(client: ASCClient, bundle_id: str) -> dict:
+    data = client.get_json("/apps", params={"filter[bundleId]": bundle_id, "limit": 1})
+    apps = data.get("data", [])
+    if not apps:
+        raise RuntimeError(f"No app found for bundle id {bundle_id!r}")
+    return apps[0]
+
+
+def find_mac_app_info(client: ASCClient, app_id: str) -> dict:
+    data = client.get_json(f"/apps/{app_id}/appInfos")
+    for info in data.get("data", []):
+        attrs = info.get("attributes", {})
+        if attrs.get("appStoreState") is not None or attrs.get("brazilAgeRating") is not None:
+            # Prefer MAC_OS if platform is listed in included relationships; filter by bundle platform.
+            pass
+    # App infos are per platform — pick MAC_OS via appStoreAgeRating or secondary query.
+    for info in data.get("data", []):
+        info_id = info["id"]
+        detail = client.get_json(f"/appInfos/{info_id}")
+        platform = detail.get("data", {}).get("attributes", {}).get("platform")
+        if platform == "MAC_OS":
+            return detail["data"]
+    # Fallback: single app info entry.
+    infos = data.get("data", [])
+    if len(infos) == 1:
+        return infos[0]
+    raise RuntimeError("Could not determine MAC_OS appInfo; inspect /apps/{id}/appInfos")
+
+
+def find_app_info_localization(client: ASCClient, app_info_id: str, locale: str) -> dict:
+    data = client.get_json(f"/appInfos/{app_info_id}/appInfoLocalizations")
+    for loc in data.get("data", []):
+        if loc.get("attributes", {}).get("locale") == locale:
+            return loc
+    raise RuntimeError(f"No appInfoLocalization for locale {locale!r}")
+
+
+def find_version(client: ASCClient, app_id: str, version_string: str) -> dict:
+    data = client.get_json(
+        f"/apps/{app_id}/appStoreVersions",
+        params={"filter[platform]": "MAC_OS", "limit": 50},
+    )
+    matches = [
+        v
+        for v in data.get("data", [])
+        if v.get("attributes", {}).get("versionString") == version_string
+    ]
+    if not matches:
+        raise RuntimeError(f"No MAC_OS appStoreVersion {version_string!r}")
+    # Prefer editable state if multiple.
+    for v in matches:
+        state = v.get("attributes", {}).get("appStoreState")
+        if state in ("PREPARE_FOR_SUBMISSION", "DEVELOPER_REJECTED", "REJECTED", "METADATA_REJECTED"):
+            return v
+    return matches[0]
+
+
+def find_version_localization(client: ASCClient, version_id: str, locale: str) -> dict:
+    data = client.get_json(f"/appStoreVersions/{version_id}/appStoreVersionLocalizations")
+    for loc in data.get("data", []):
+        if loc.get("attributes", {}).get("locale") == locale:
+            return loc
+    raise RuntimeError(f"No appStoreVersionLocalization for locale {locale!r}")
+
+
+def main() -> int:
+    if not ENV_PATH.exists():
+        print(f"Missing {ENV_PATH}", file=sys.stderr)
+        return 1
+
+    env = load_env(ENV_PATH)
+    issuer = env.get("APP_STORE_CONNECT_ISSUER_ID", "")
+    key_id = env.get("APP_STORE_CONNECT_KEY_ID", "")
+    key_path = env.get("APP_STORE_CONNECT_PRIVATE_KEY_PATH", "")
+    bundle_id = env.get("APP_STORE_CONNECT_BUNDLE_ID", "")
+
+    for key, value in [
+        ("APP_STORE_CONNECT_ISSUER_ID", issuer),
+        ("APP_STORE_CONNECT_KEY_ID", key_id),
+        ("APP_STORE_CONNECT_PRIVATE_KEY_PATH", key_path),
+        ("APP_STORE_CONNECT_BUNDLE_ID", bundle_id),
+    ]:
+        if not value:
+            print(f"Set {key} in {ENV_PATH}", file=sys.stderr)
+            return 1
+
+    client = ASCClient(issuer, key_id, key_path)
+    app = find_app(client, bundle_id)
+    app_id = app["id"]
+    print(f"App: {app['attributes'].get('name')} ({app_id})")
+
+    app_info = find_mac_app_info(client, app_id)
+    app_info_id = app_info["id"]
+    print(f"App Information (MAC_OS): {app_info_id}")
+
+    info_loc = find_app_info_localization(client, app_info_id, LOCALE)
+    info_loc_id = info_loc["id"]
+
+    app_name = read_field("app-name.txt")
+    subtitle = read_field("subtitle.txt")
+    privacy_url = read_field("link-privacy.txt")
+
+    info_patch = {
+        "name": app_name,
+        "subtitle": subtitle,
+        "privacyPolicyUrl": privacy_url,
+    }
+    client.patch("appInfoLocalizations", info_loc_id, info_patch)
+    print(f"Updated App Information localization ({LOCALE}): name, subtitle, privacyPolicyUrl")
+
+    version = find_version(client, app_id, VERSION_STRING)
+    version_id = version["id"]
+    version_state = version.get("attributes", {}).get("appStoreState")
+    print(f"Version {VERSION_STRING}: {version_id} (state: {version_state})")
+
+    copyright_text = read_field("copyright.txt")
+    client.patch("appStoreVersions", version_id, {"copyright": copyright_text})
+    print(f"Updated version copyright")
+
+    version_loc = find_version_localization(client, version_id, LOCALE)
+    version_loc_id = version_loc["id"]
+
+    version_patch = {
+        "description": read_field("description.txt"),
+        "keywords": read_field("keywords.txt"),
+        "promotionalText": read_field("promotional-text.txt"),
+        "marketingUrl": read_field("link-marketing.txt"),
+        "supportUrl": read_field("link-support.txt"),
+    }
+    client.patch("appStoreVersionLocalizations", version_loc_id, version_patch)
+    print(f"Updated version {VERSION_STRING} localization ({LOCALE}): description, keywords, promotionalText, marketingUrl, supportUrl")
+
+    # Custom EULA URL (Terms) — attach to app if not using standard Apple EULA.
+    terms_url = read_field("link-terms.txt")
+    try:
+        eula_data = client.get_json(f"/apps/{app_id}/endUserLicenseAgreement")
+        eula = eula_data.get("data")
+        if eula:
+            # Custom EULA resource uses agreementText, not URL in all cases; try app-level license.
+            print("App has custom EULA resource; update Terms URL in App Store Connect if needed:", terms_url)
+        else:
+            print("Using standard Apple EULA; Terms URL for reference:", terms_url)
+    except RuntimeError:
+        print("EULA lookup skipped; Terms URL:", terms_url)
+
+    print("Done.")
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())