@daemux/store-automator 0.10.23 → 0.10.24

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.
@@ -5,14 +5,14 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "App Store & Google Play automation for Flutter apps",
8
- "version": "0.10.23"
8
+ "version": "0.10.24"
9
9
  },
10
10
  "plugins": [
11
11
  {
12
12
  "name": "store-automator",
13
13
  "source": "./plugins/store-automator",
14
14
  "description": "3 agents for app store publishing: reviewer, meta-creator, media-designer",
15
- "version": "0.10.23",
15
+ "version": "0.10.24",
16
16
  "keywords": [
17
17
  "flutter",
18
18
  "app-store",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daemux/store-automator",
3
- "version": "0.10.23",
3
+ "version": "0.10.24",
4
4
  "description": "Full App Store & Google Play automation for Flutter apps with Claude Code agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "store-automator",
3
- "version": "0.10.23",
3
+ "version": "0.10.24",
4
4
  "description": "App Store & Google Play automation agents for Flutter app publishing",
5
5
  "author": {
6
6
  "name": "Daemux"
@@ -20,6 +20,10 @@
20
20
  "prices": {
21
21
  "USD": "9.99"
22
22
  },
23
+ "availability": {
24
+ "available_in_new_territories": true
25
+ },
26
+ "review_screenshot": "fastlane/screenshots/review/subscription_review.png",
23
27
  "introductory_offer": {
24
28
  "type": "FREE",
25
29
  "duration": "ONE_WEEK",
@@ -40,6 +44,10 @@
40
44
  "prices": {
41
45
  "USD": "69.99"
42
46
  },
47
+ "availability": {
48
+ "available_in_new_territories": true
49
+ },
50
+ "review_screenshot": "fastlane/screenshots/review/subscription_review.png",
43
51
  "introductory_offer": {
44
52
  "type": "FREE",
45
53
  "duration": "ONE_MONTH",
@@ -0,0 +1,258 @@
1
+ """
2
+ App Store Connect Subscription Setup API layer.
3
+
4
+ Functions for managing subscription availability (territories),
5
+ pricing (price points), and review screenshot uploads.
6
+ """
7
+ import sys
8
+
9
+ try:
10
+ import requests
11
+ except ImportError:
12
+ import subprocess
13
+ subprocess.check_call(
14
+ [sys.executable, "-m", "pip", "install", "--break-system-packages", "requests"],
15
+ stdout=subprocess.DEVNULL,
16
+ )
17
+ import requests
18
+
19
+ from asc_iap_api import BASE_URL, TIMEOUT, print_api_errors
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Availability
24
+ # ---------------------------------------------------------------------------
25
+
26
+ def get_subscription_availability(headers: dict, sub_id: str) -> dict | None:
27
+ """Fetch current availability settings for a subscription."""
28
+ resp = requests.get(
29
+ f"{BASE_URL}/subscriptions/{sub_id}/subscriptionAvailability",
30
+ headers=headers,
31
+ timeout=TIMEOUT,
32
+ )
33
+ if not resp.ok:
34
+ if resp.status_code == 404:
35
+ return None
36
+ print_api_errors(resp, f"get availability for subscription {sub_id}")
37
+ return None
38
+ return resp.json().get("data")
39
+
40
+
41
+ def create_subscription_availability(
42
+ headers: dict,
43
+ sub_id: str,
44
+ territory_ids: list[str],
45
+ available_in_new: bool = True,
46
+ ) -> dict | None:
47
+ """Create availability with specified territories for a subscription."""
48
+ territory_data = [
49
+ {"type": "territories", "id": tid} for tid in territory_ids
50
+ ]
51
+ resp = requests.post(
52
+ f"{BASE_URL}/subscriptionAvailabilities",
53
+ json={
54
+ "data": {
55
+ "type": "subscriptionAvailabilities",
56
+ "attributes": {
57
+ "availableInNewTerritories": available_in_new,
58
+ },
59
+ "relationships": {
60
+ "subscription": {
61
+ "data": {"type": "subscriptions", "id": sub_id},
62
+ },
63
+ "availableTerritories": {
64
+ "data": territory_data,
65
+ },
66
+ },
67
+ }
68
+ },
69
+ headers=headers,
70
+ timeout=TIMEOUT,
71
+ )
72
+ if not resp.ok:
73
+ print_api_errors(resp, f"create availability for subscription {sub_id}")
74
+ return None
75
+ print(f" Set availability for subscription {sub_id} ({len(territory_ids)} territories)")
76
+ return resp.json().get("data")
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # Pricing
81
+ # ---------------------------------------------------------------------------
82
+
83
+ def get_subscription_prices(headers: dict, sub_id: str) -> list:
84
+ """List existing prices for a subscription."""
85
+ resp = requests.get(
86
+ f"{BASE_URL}/subscriptions/{sub_id}/prices",
87
+ params={"include": "subscriptionPricePoint"},
88
+ headers=headers,
89
+ timeout=TIMEOUT,
90
+ )
91
+ if not resp.ok:
92
+ print_api_errors(resp, f"get prices for subscription {sub_id}")
93
+ return []
94
+ return resp.json().get("data", [])
95
+
96
+
97
+ def get_price_points_for_territory(
98
+ headers: dict, sub_id: str, territory: str,
99
+ ) -> list:
100
+ """Get available price points for a subscription in a given territory."""
101
+ resp = requests.get(
102
+ f"{BASE_URL}/subscriptions/{sub_id}/pricePoints",
103
+ params={
104
+ "filter[territory]": territory,
105
+ "include": "territory",
106
+ },
107
+ headers=headers,
108
+ timeout=TIMEOUT,
109
+ )
110
+ if not resp.ok:
111
+ print_api_errors(resp, f"get price points for {territory}")
112
+ return []
113
+ return resp.json().get("data", [])
114
+
115
+
116
+ def find_price_point_by_amount(
117
+ price_points: list, amount_str: str,
118
+ ) -> dict | None:
119
+ """Find a price point matching the given customer price string."""
120
+ for pp in price_points:
121
+ customer_price = pp.get("attributes", {}).get("customerPrice", "")
122
+ if customer_price == amount_str:
123
+ return pp
124
+ return None
125
+
126
+
127
+ def create_subscription_price(
128
+ headers: dict,
129
+ sub_id: str,
130
+ price_point_id: str,
131
+ start_date: str | None = None,
132
+ ) -> dict | None:
133
+ """Create a price entry for a subscription using a price point ID."""
134
+ resp = requests.post(
135
+ f"{BASE_URL}/subscriptionPrices",
136
+ json={
137
+ "data": {
138
+ "type": "subscriptionPrices",
139
+ "attributes": {
140
+ "startDate": start_date,
141
+ "preserveCurrentPrice": False,
142
+ },
143
+ "relationships": {
144
+ "subscription": {
145
+ "data": {"type": "subscriptions", "id": sub_id},
146
+ },
147
+ "subscriptionPricePoint": {
148
+ "data": {
149
+ "type": "subscriptionPricePoints",
150
+ "id": price_point_id,
151
+ },
152
+ },
153
+ },
154
+ }
155
+ },
156
+ headers=headers,
157
+ timeout=TIMEOUT,
158
+ )
159
+ if not resp.ok:
160
+ print_api_errors(resp, f"create price for subscription {sub_id}")
161
+ return None
162
+ print(f" Set price for subscription {sub_id} (price point: {price_point_id})")
163
+ return resp.json().get("data")
164
+
165
+
166
+ # ---------------------------------------------------------------------------
167
+ # Review Screenshot
168
+ # ---------------------------------------------------------------------------
169
+
170
+ def get_review_screenshot(headers: dict, sub_id: str) -> dict | None:
171
+ """Fetch the current review screenshot for a subscription."""
172
+ resp = requests.get(
173
+ f"{BASE_URL}/subscriptions/{sub_id}/appStoreReviewScreenshot",
174
+ headers=headers,
175
+ timeout=TIMEOUT,
176
+ )
177
+ if not resp.ok:
178
+ if resp.status_code == 404:
179
+ return None
180
+ print_api_errors(resp, f"get review screenshot for subscription {sub_id}")
181
+ return None
182
+ return resp.json().get("data")
183
+
184
+
185
+ def reserve_review_screenshot(
186
+ headers: dict, sub_id: str, file_name: str, file_size: int,
187
+ ) -> dict | None:
188
+ """Reserve a review screenshot upload slot for a subscription."""
189
+ resp = requests.post(
190
+ f"{BASE_URL}/subscriptionAppStoreReviewScreenshots",
191
+ json={
192
+ "data": {
193
+ "type": "subscriptionAppStoreReviewScreenshots",
194
+ "attributes": {
195
+ "fileName": file_name,
196
+ "fileSize": file_size,
197
+ },
198
+ "relationships": {
199
+ "subscription": {
200
+ "data": {"type": "subscriptions", "id": sub_id},
201
+ },
202
+ },
203
+ }
204
+ },
205
+ headers=headers,
206
+ timeout=TIMEOUT,
207
+ )
208
+ if not resp.ok:
209
+ print_api_errors(resp, f"reserve screenshot for subscription {sub_id}")
210
+ return None
211
+ return resp.json().get("data")
212
+
213
+
214
+ def upload_screenshot_chunks(
215
+ upload_operations: list, file_data: bytes,
216
+ ) -> bool:
217
+ """Upload binary chunks to pre-signed URLs (no Authorization header)."""
218
+ for op in upload_operations:
219
+ url = op["url"]
220
+ offset = op["offset"]
221
+ length = op["length"]
222
+ chunk = file_data[offset : offset + length]
223
+ op_headers = {h["name"]: h["value"] for h in op.get("requestHeaders", [])}
224
+ resp = requests.put(url, headers=op_headers, data=chunk, timeout=TIMEOUT)
225
+ if not resp.ok:
226
+ print(
227
+ f"ERROR (upload chunk at offset {offset}): "
228
+ f"HTTP {resp.status_code} - {resp.text[:200]}",
229
+ file=sys.stderr,
230
+ )
231
+ return False
232
+ return True
233
+
234
+
235
+ def commit_review_screenshot(
236
+ headers: dict, screenshot_id: str, md5_checksum: str,
237
+ ) -> dict | None:
238
+ """Commit an uploaded review screenshot by confirming its checksum."""
239
+ resp = requests.patch(
240
+ f"{BASE_URL}/subscriptionAppStoreReviewScreenshots/{screenshot_id}",
241
+ json={
242
+ "data": {
243
+ "type": "subscriptionAppStoreReviewScreenshots",
244
+ "id": screenshot_id,
245
+ "attributes": {
246
+ "sourceFileChecksum": md5_checksum,
247
+ "uploaded": True,
248
+ },
249
+ }
250
+ },
251
+ headers=headers,
252
+ timeout=TIMEOUT,
253
+ )
254
+ if not resp.ok:
255
+ print_api_errors(resp, f"commit screenshot {screenshot_id}")
256
+ return None
257
+ print(f" Committed review screenshot (ID: {screenshot_id})")
258
+ return resp.json().get("data")
@@ -44,6 +44,7 @@ export APP_STORE_CONNECT_KEY_IDENTIFIER="$APPLE_KEY_ID"
44
44
  export APP_STORE_CONNECT_ISSUER_ID="$APPLE_ISSUER_ID"
45
45
  export APP_STORE_CONNECT_PRIVATE_KEY="$(cat "$P8_FULL_PATH")"
46
46
  export BUNDLE_ID="$BUNDLE_ID"
47
+ export PROJECT_ROOT="$PROJECT_ROOT"
47
48
 
48
49
  echo "ASC API key configured (Key ID: $APPLE_KEY_ID)"
49
50
 
@@ -10,10 +10,12 @@ Required env vars:
10
10
  APP_STORE_CONNECT_ISSUER_ID - Issuer ID from App Store Connect
11
11
  APP_STORE_CONNECT_PRIVATE_KEY - Contents of the P8 key file
12
12
  BUNDLE_ID - App bundle identifier
13
+ PROJECT_ROOT - Absolute path to the project root
13
14
 
14
15
  Usage:
15
16
  python3 sync_iap_ios.py <path/to/iap_config.json>
16
17
  """
18
+ import hashlib
17
19
  import json
18
20
  import os
19
21
  import sys
@@ -29,6 +31,32 @@ from asc_iap_api import (
29
31
  list_subscriptions_in_group,
30
32
  update_localization,
31
33
  )
34
+ from asc_subscription_setup import (
35
+ commit_review_screenshot,
36
+ create_subscription_availability,
37
+ create_subscription_price,
38
+ find_price_point_by_amount,
39
+ get_price_points_for_territory,
40
+ get_review_screenshot,
41
+ get_subscription_availability,
42
+ get_subscription_prices,
43
+ reserve_review_screenshot,
44
+ upload_screenshot_chunks,
45
+ )
46
+
47
+ CURRENCY_TO_TERRITORY = {
48
+ "USD": "USA", "EUR": "FRA", "GBP": "GBR", "JPY": "JPN",
49
+ "AUD": "AUS", "CAD": "CAN", "CHF": "CHE", "CNY": "CHN",
50
+ "KRW": "KOR", "SEK": "SWE", "NOK": "NOR", "DKK": "DNK",
51
+ "INR": "IND", "BRL": "BRA", "MXN": "MEX", "RUB": "RUS",
52
+ "TRY": "TUR", "SAR": "SAU", "AED": "ARE", "HKD": "HKG",
53
+ "SGD": "SGP", "NZD": "NZL", "TWD": "TWN", "THB": "THA",
54
+ "MYR": "MYS", "PHP": "PHL", "IDR": "IDN", "ILS": "ISR",
55
+ "ZAR": "ZAF", "PLN": "POL", "CZK": "CZE", "HUF": "HUN",
56
+ "RON": "ROU", "BGN": "BGR", "HRK": "HRV", "COP": "COL",
57
+ "CLP": "CHL", "PEN": "PER", "EGP": "EGY", "NGN": "NGA",
58
+ "PKR": "PAK", "KZT": "KAZ", "QAR": "QAT", "KWD": "KWT",
59
+ }
32
60
 
33
61
 
34
62
  def find_or_create_group(headers: dict, app_id: str, reference_name: str, existing_groups: list) -> str:
@@ -62,7 +90,10 @@ def set_subscription_localization(headers: dict, sub_id: str, locale: str, loc_d
62
90
  create_localization(headers, sub_id, locale, loc_data)
63
91
 
64
92
 
65
- def sync_subscription_group(headers: dict, app_id: str, group_config: dict, existing_groups: list) -> dict:
93
+ def sync_subscription_group(
94
+ headers: dict, app_id: str, group_config: dict,
95
+ existing_groups: list, project_root: str,
96
+ ) -> dict:
66
97
  """Sync a single subscription group and its subscriptions. Returns sync result."""
67
98
  ref_name = group_config.get("reference_name", group_config.get("group_name", ""))
68
99
  if not ref_name:
@@ -77,43 +108,124 @@ def sync_subscription_group(headers: dict, app_id: str, group_config: dict, exis
77
108
 
78
109
  for sub_config in group_config.get("subscriptions", []):
79
110
  sub_id = find_or_create_subscription(headers, group_id, sub_config, existing_subs)
80
- _sync_subscription_localizations(headers, sub_id, sub_config)
111
+ for locale, loc_data in sub_config.get("localizations", {}).items():
112
+ set_subscription_localization(headers, sub_id, locale, loc_data)
113
+ _sync_availability(headers, sub_id, sub_config)
114
+ _sync_pricing(headers, sub_id, sub_config)
115
+ _sync_review_screenshot(headers, sub_id, sub_config, project_root)
81
116
  sub_results.append({"product_id": sub_config["product_id"], "id": sub_id})
82
117
 
83
118
  return {"group": ref_name, "group_id": group_id, "subscriptions": sub_results}
84
119
 
85
120
 
86
- def _sync_subscription_localizations(headers: dict, sub_id: str, sub_config: dict) -> None:
87
- """Sync localizations for a subscription from its config."""
88
- localizations = sub_config.get("localizations", {})
89
- if not localizations:
121
+ def _sync_availability(headers: dict, sub_id: str, sub_config: dict) -> None:
122
+ """Ensure subscription territory availability is configured."""
123
+ avail_config = sub_config.get("availability", {})
124
+ available_in_new = avail_config.get("available_in_new_territories", True)
125
+ territory_ids = avail_config.get("territories", [])
126
+
127
+ existing = get_subscription_availability(headers, sub_id)
128
+ if existing:
129
+ print(" Subscription availability already configured")
90
130
  return
91
- for locale, loc_data in localizations.items():
92
- set_subscription_localization(headers, sub_id, locale, loc_data)
93
131
 
132
+ result = create_subscription_availability(
133
+ headers, sub_id, territory_ids, available_in_new=available_in_new,
134
+ )
135
+ if result:
136
+ print(" Subscription availability configured successfully")
137
+ else:
138
+ print(" WARNING: Failed to configure availability", file=sys.stderr)
94
139
 
95
- def validate_env() -> tuple:
96
- """Validate required environment variables. Returns (key_id, issuer_id, private_key, bundle_id)."""
97
- key_id = os.environ.get("APP_STORE_CONNECT_KEY_IDENTIFIER", "")
98
- issuer_id = os.environ.get("APP_STORE_CONNECT_ISSUER_ID", "")
99
- private_key = os.environ.get("APP_STORE_CONNECT_PRIVATE_KEY", "")
100
- bundle_id = os.environ.get("BUNDLE_ID", "")
101
-
102
- missing = []
103
- if not key_id:
104
- missing.append("APP_STORE_CONNECT_KEY_IDENTIFIER")
105
- if not issuer_id:
106
- missing.append("APP_STORE_CONNECT_ISSUER_ID")
107
- if not private_key:
108
- missing.append("APP_STORE_CONNECT_PRIVATE_KEY")
109
- if not bundle_id:
110
- missing.append("BUNDLE_ID")
111
140
 
141
+ def _sync_pricing(headers: dict, sub_id: str, sub_config: dict) -> None:
142
+ """Set subscription prices per territory from config."""
143
+ prices = sub_config.get("prices", {})
144
+ if not prices:
145
+ print(" WARNING: No prices configured, skipping pricing", file=sys.stderr)
146
+ return
147
+
148
+ existing_prices = get_subscription_prices(headers, sub_id)
149
+ if existing_prices:
150
+ print(" Pricing already configured")
151
+ return
152
+
153
+ for currency, amount in prices.items():
154
+ territory = CURRENCY_TO_TERRITORY.get(currency)
155
+ if not territory:
156
+ print(f" WARNING: Unknown currency '{currency}', skipping", file=sys.stderr)
157
+ continue
158
+ price_points = get_price_points_for_territory(headers, sub_id, territory)
159
+ point = find_price_point_by_amount(price_points, amount)
160
+ if not point:
161
+ print(f" WARNING: No price point matching {amount} for {territory}", file=sys.stderr)
162
+ continue
163
+ result = create_subscription_price(headers, sub_id, point["id"])
164
+ if result:
165
+ print(f" Set price {amount} for territory {territory}")
166
+ else:
167
+ print(f" WARNING: Failed to set price for {territory}", file=sys.stderr)
168
+
169
+
170
+ def _sync_review_screenshot(
171
+ headers: dict, sub_id: str, sub_config: dict, project_root: str,
172
+ ) -> None:
173
+ """Upload a review screenshot for the subscription if configured."""
174
+ screenshot_path = sub_config.get("review_screenshot")
175
+ if not screenshot_path:
176
+ print(" WARNING: No review_screenshot configured, skipping", file=sys.stderr)
177
+ return
178
+
179
+ full_path = os.path.join(project_root, screenshot_path)
180
+ if not os.path.isfile(full_path):
181
+ print(f" WARNING: Screenshot not found: {full_path}", file=sys.stderr)
182
+ return
183
+
184
+ existing = get_review_screenshot(headers, sub_id)
185
+ if existing:
186
+ print(" Review screenshot already uploaded")
187
+ return
188
+
189
+ with open(full_path, "rb") as f:
190
+ file_data = f.read()
191
+
192
+ file_name = os.path.basename(full_path)
193
+ md5_checksum = hashlib.md5(file_data).hexdigest()
194
+
195
+ reservation = reserve_review_screenshot(headers, sub_id, file_name, len(file_data))
196
+ if not reservation:
197
+ print(" WARNING: Failed to reserve screenshot upload", file=sys.stderr)
198
+ return
199
+
200
+ screenshot_id = reservation["id"]
201
+ upload_ops = reservation["attributes"].get("uploadOperations", [])
202
+
203
+ success = upload_screenshot_chunks(upload_ops, file_data)
204
+ if not success:
205
+ print(" WARNING: Screenshot chunk upload failed, skipping commit", file=sys.stderr)
206
+ return
207
+ result = commit_review_screenshot(headers, screenshot_id, md5_checksum)
208
+ if result:
209
+ print(f" Review screenshot uploaded: {file_name}")
210
+ else:
211
+ print(" WARNING: Failed to commit screenshot upload", file=sys.stderr)
212
+
213
+
214
+ def validate_env() -> tuple:
215
+ """Validate required environment variables. Returns (key_id, issuer_id, private_key, bundle_id, project_root)."""
216
+ required_vars = [
217
+ "APP_STORE_CONNECT_KEY_IDENTIFIER",
218
+ "APP_STORE_CONNECT_ISSUER_ID",
219
+ "APP_STORE_CONNECT_PRIVATE_KEY",
220
+ "BUNDLE_ID",
221
+ "PROJECT_ROOT",
222
+ ]
223
+ values = {var: os.environ.get(var, "") for var in required_vars}
224
+ missing = [var for var, val in values.items() if not val]
112
225
  if missing:
113
226
  print(f"ERROR: Missing required environment variables: {', '.join(missing)}", file=sys.stderr)
114
227
  sys.exit(1)
115
-
116
- return key_id, issuer_id, private_key, bundle_id
228
+ return tuple(values.values())
117
229
 
118
230
 
119
231
  def load_iap_config(config_path: str) -> dict:
@@ -137,7 +249,7 @@ def main() -> None:
137
249
  sys.exit(1)
138
250
 
139
251
  config_path = sys.argv[1]
140
- key_id, issuer_id, private_key, bundle_id = validate_env()
252
+ key_id, issuer_id, private_key, bundle_id, project_root = validate_env()
141
253
 
142
254
  token = get_jwt_token(key_id, issuer_id, private_key)
143
255
  headers = {
@@ -153,7 +265,9 @@ def main() -> None:
153
265
  results = []
154
266
 
155
267
  for group_config in config.get("subscription_groups", []):
156
- result = sync_subscription_group(headers, app_id, group_config, existing_groups)
268
+ result = sync_subscription_group(
269
+ headers, app_id, group_config, existing_groups, project_root,
270
+ )
157
271
  results.append(result)
158
272
 
159
273
  print(f"\n{json.dumps({'synced_groups': results}, indent=2)}")