@daemux/store-automator 0.10.33 → 0.10.34

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,15 +5,20 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "App Store & Google Play automation for Flutter apps",
8
- "version": "0.10.31"
8
+ "version": "0.10.34"
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.31",
16
- "keywords": ["flutter", "app-store", "google-play", "fastlane"]
15
+ "version": "0.10.34",
16
+ "keywords": [
17
+ "flutter",
18
+ "app-store",
19
+ "google-play",
20
+ "fastlane"
21
+ ]
17
22
  }
18
23
  ]
19
24
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daemux/store-automator",
3
- "version": "0.10.33",
3
+ "version": "0.10.34",
4
4
  "description": "Full App Store & Google Play automation for Flutter apps with Claude Code agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,9 +1,16 @@
1
1
  {
2
2
  "name": "store-automator",
3
- "version": "0.10.3",
3
+ "version": "0.10.34",
4
4
  "description": "App Store & Google Play automation agents for Flutter app publishing",
5
5
  "author": {
6
6
  "name": "Daemux"
7
7
  },
8
- "keywords": ["flutter", "app-store", "google-play", "fastlane", "screenshots", "metadata"]
8
+ "keywords": [
9
+ "flutter",
10
+ "app-store",
11
+ "google-play",
12
+ "fastlane",
13
+ "screenshots",
14
+ "metadata"
15
+ ]
9
16
  }
@@ -57,7 +57,6 @@ def metadata_deliver_options
57
57
  sync_screenshots: true,
58
58
  primary_category: ENV.fetch("PRIMARY_CATEGORY", "UTILITIES"),
59
59
  secondary_category: ENV.fetch("SECONDARY_CATEGORY", "PRODUCTIVITY"),
60
- price_tier: ENV.fetch("PRICE_TIER", "0").to_i,
61
60
  app_rating_config_path: "#{ROOT_DIR}/fastlane/app_rating_config.json"
62
61
  )
63
62
  end
@@ -66,17 +66,27 @@ def create_subscription_availability(
66
66
  available_in_new: bool = True,
67
67
  ) -> dict | None:
68
68
  """Create availability with specified territories for a subscription."""
69
- territory_data = [{"type": "territories", "id": tid} for tid in territory_ids]
69
+ territory_data = [
70
+ {"type": "territories", "id": tid} for tid in territory_ids
71
+ ]
70
72
  resp = requests.post(
71
73
  f"{BASE_URL}/subscriptionAvailabilities",
72
- json={"data": {
73
- "type": "subscriptionAvailabilities",
74
- "attributes": {"availableInNewTerritories": available_in_new},
75
- "relationships": {
76
- "subscription": {"data": {"type": "subscriptions", "id": sub_id}},
77
- "availableTerritories": {"data": territory_data},
78
- },
79
- }},
74
+ json={
75
+ "data": {
76
+ "type": "subscriptionAvailabilities",
77
+ "attributes": {
78
+ "availableInNewTerritories": available_in_new,
79
+ },
80
+ "relationships": {
81
+ "subscription": {
82
+ "data": {"type": "subscriptions", "id": sub_id},
83
+ },
84
+ "availableTerritories": {
85
+ "data": territory_data,
86
+ },
87
+ },
88
+ }
89
+ },
80
90
  headers=headers,
81
91
  timeout=TIMEOUT,
82
92
  )
@@ -162,16 +172,26 @@ def create_subscription_price(
162
172
  """Create a price entry for a subscription using a price point ID."""
163
173
  resp = requests.post(
164
174
  f"{BASE_URL}/subscriptionPrices",
165
- json={"data": {
166
- "type": "subscriptionPrices",
167
- "attributes": {"startDate": start_date, "preserveCurrentPrice": False},
168
- "relationships": {
169
- "subscription": {"data": {"type": "subscriptions", "id": sub_id}},
170
- "subscriptionPricePoint": {
171
- "data": {"type": "subscriptionPricePoints", "id": price_point_id},
175
+ json={
176
+ "data": {
177
+ "type": "subscriptionPrices",
178
+ "attributes": {
179
+ "startDate": start_date,
180
+ "preserveCurrentPrice": False,
172
181
  },
173
- },
174
- }},
182
+ "relationships": {
183
+ "subscription": {
184
+ "data": {"type": "subscriptions", "id": sub_id},
185
+ },
186
+ "subscriptionPricePoint": {
187
+ "data": {
188
+ "type": "subscriptionPricePoints",
189
+ "id": price_point_id,
190
+ },
191
+ },
192
+ },
193
+ }
194
+ },
175
195
  headers=headers,
176
196
  timeout=TIMEOUT,
177
197
  )
@@ -201,49 +221,72 @@ def get_review_screenshot(headers: dict, sub_id: str) -> dict | None:
201
221
  return resp.json().get("data")
202
222
 
203
223
 
204
- def upload_review_screenshot(
205
- headers: dict, sub_id: str,
206
- file_name: str, file_data: bytes, md5_checksum: str,
224
+ def reserve_review_screenshot(
225
+ headers: dict, sub_id: str, file_name: str, file_size: int,
207
226
  ) -> dict | None:
208
- """Reserve, upload chunks, and commit a review screenshot in one operation."""
209
- resource_type = "subscriptionAppStoreReviewScreenshots"
227
+ """Reserve a review screenshot upload slot for a subscription."""
210
228
  resp = requests.post(
211
- f"{BASE_URL}/{resource_type}",
212
- json={"data": {
213
- "type": resource_type,
214
- "attributes": {"fileName": file_name, "fileSize": len(file_data)},
215
- "relationships": {
216
- "subscription": {"data": {"type": "subscriptions", "id": sub_id}},
217
- },
218
- }},
229
+ f"{BASE_URL}/subscriptionAppStoreReviewScreenshots",
230
+ json={
231
+ "data": {
232
+ "type": "subscriptionAppStoreReviewScreenshots",
233
+ "attributes": {
234
+ "fileName": file_name,
235
+ "fileSize": file_size,
236
+ },
237
+ "relationships": {
238
+ "subscription": {
239
+ "data": {"type": "subscriptions", "id": sub_id},
240
+ },
241
+ },
242
+ }
243
+ },
219
244
  headers=headers,
220
245
  timeout=TIMEOUT,
221
246
  )
222
247
  if not resp.ok:
223
248
  print_api_errors(resp, f"reserve screenshot for subscription {sub_id}")
224
249
  return None
225
- reservation = resp.json().get("data")
226
- if not reservation:
227
- print(f"ERROR: Empty reservation response for subscription {sub_id}", file=sys.stderr)
228
- return None
229
- screenshot_id = reservation["id"]
230
- for op in reservation["attributes"].get("uploadOperations", []):
231
- chunk = file_data[op["offset"] : op["offset"] + op["length"]]
250
+ return resp.json().get("data")
251
+
252
+
253
+ def upload_screenshot_chunks(
254
+ upload_operations: list, file_data: bytes,
255
+ ) -> bool:
256
+ """Upload binary chunks to pre-signed URLs (no Authorization header)."""
257
+ for op in upload_operations:
258
+ url = op["url"]
259
+ offset = op["offset"]
260
+ length = op["length"]
261
+ chunk = file_data[offset : offset + length]
232
262
  op_headers = {h["name"]: h["value"] for h in op.get("requestHeaders", [])}
233
- chunk_resp = requests.put(op["url"], headers=op_headers, data=chunk, timeout=TIMEOUT)
234
- if not chunk_resp.ok:
263
+ resp = requests.put(url, headers=op_headers, data=chunk, timeout=TIMEOUT)
264
+ if not resp.ok:
235
265
  print(
236
- f"ERROR (upload chunk at offset {op['offset']}): "
237
- f"HTTP {chunk_resp.status_code} - {chunk_resp.text[:200]}",
266
+ f"ERROR (upload chunk at offset {offset}): "
267
+ f"HTTP {resp.status_code} - {resp.text[:200]}",
238
268
  file=sys.stderr,
239
269
  )
240
- return None
270
+ return False
271
+ return True
272
+
273
+
274
+ def commit_review_screenshot(
275
+ headers: dict, screenshot_id: str, md5_checksum: str,
276
+ ) -> dict | None:
277
+ """Commit an uploaded review screenshot by confirming its checksum."""
241
278
  resp = requests.patch(
242
- f"{BASE_URL}/{resource_type}/{screenshot_id}",
243
- json={"data": {
244
- "type": resource_type, "id": screenshot_id,
245
- "attributes": {"sourceFileChecksum": md5_checksum, "uploaded": True},
246
- }},
279
+ f"{BASE_URL}/subscriptionAppStoreReviewScreenshots/{screenshot_id}",
280
+ json={
281
+ "data": {
282
+ "type": "subscriptionAppStoreReviewScreenshots",
283
+ "id": screenshot_id,
284
+ "attributes": {
285
+ "sourceFileChecksum": md5_checksum,
286
+ "uploaded": True,
287
+ },
288
+ }
289
+ },
247
290
  headers=headers,
248
291
  timeout=TIMEOUT,
249
292
  )
@@ -35,6 +35,7 @@ from asc_iap_api import (
35
35
  update_localization,
36
36
  )
37
37
  from asc_subscription_setup import (
38
+ commit_review_screenshot,
38
39
  create_subscription_availability,
39
40
  create_subscription_price,
40
41
  find_price_point_by_amount,
@@ -43,7 +44,8 @@ from asc_subscription_setup import (
43
44
  get_subscription_availability,
44
45
  get_subscription_prices,
45
46
  list_all_territory_ids,
46
- upload_review_screenshot,
47
+ reserve_review_screenshot,
48
+ upload_screenshot_chunks,
47
49
  )
48
50
 
49
51
  CURRENCY_TO_TERRITORY = {
@@ -223,12 +225,27 @@ def _sync_review_screenshot(
223
225
  return
224
226
 
225
227
  full_path = os.path.join(project_root, screenshot_path)
228
+
229
+ # Fallback: if configured path doesn't exist, pick first iPhone screenshot
226
230
  if not os.path.isfile(full_path):
227
- full_path = _find_fallback_screenshot(project_root)
228
- if not full_path:
229
- print(f" WARNING: Screenshot not found: {screenshot_path}", file=sys.stderr)
231
+ fallback = None
232
+ ios_dir = os.path.join(project_root, "fastlane", "screenshots", "ios", "en-US")
233
+ if os.path.isdir(ios_dir):
234
+ for f in sorted(os.listdir(ios_dir)):
235
+ if f.lower().endswith(".png") and "iphone" in f.lower():
236
+ fallback = os.path.join(ios_dir, f)
237
+ break
238
+ if not fallback:
239
+ for f in sorted(os.listdir(ios_dir)):
240
+ if f.lower().endswith(".png"):
241
+ fallback = os.path.join(ios_dir, f)
242
+ break
243
+ if fallback:
244
+ full_path = fallback
245
+ print(f" Using fallback screenshot: {os.path.basename(full_path)}")
246
+ else:
247
+ print(f" WARNING: Screenshot not found: {full_path}", file=sys.stderr)
230
248
  return
231
- print(f" Using fallback screenshot: {os.path.basename(full_path)}")
232
249
 
233
250
  existing = get_review_screenshot(headers, sub_id)
234
251
  if existing:
@@ -240,66 +257,79 @@ def _sync_review_screenshot(
240
257
 
241
258
  file_name = os.path.basename(full_path)
242
259
  md5_checksum = hashlib.md5(file_data).hexdigest()
243
- result = upload_review_screenshot(headers, sub_id, file_name, file_data, md5_checksum)
244
- if result:
245
- print(f" Review screenshot uploaded: {file_name}")
246
- else:
247
- print(" WARNING: Failed to upload screenshot", file=sys.stderr)
248
260
 
261
+ reservation = reserve_review_screenshot(headers, sub_id, file_name, len(file_data))
262
+ if not reservation:
263
+ print(" WARNING: Failed to reserve screenshot upload", file=sys.stderr)
264
+ return
249
265
 
250
- def _find_fallback_screenshot(project_root: str) -> str | None:
251
- """Find the first iPhone PNG screenshot in fastlane/screenshots/ios/en-US/."""
252
- ios_dir = os.path.join(project_root, "fastlane", "screenshots", "ios", "en-US")
253
- if not os.path.isdir(ios_dir):
254
- return None
255
- files = sorted(os.listdir(ios_dir))
256
- for f in files:
257
- if f.lower().endswith(".png") and "iphone" in f.lower():
258
- return os.path.join(ios_dir, f)
259
- for f in files:
260
- if f.lower().endswith(".png"):
261
- return os.path.join(ios_dir, f)
262
- return None
266
+ screenshot_id = reservation["id"]
267
+ upload_ops = reservation["attributes"].get("uploadOperations", [])
263
268
 
269
+ success = upload_screenshot_chunks(upload_ops, file_data)
270
+ if not success:
271
+ print(" WARNING: Screenshot chunk upload failed, skipping commit", file=sys.stderr)
272
+ return
273
+ result = commit_review_screenshot(headers, screenshot_id, md5_checksum)
274
+ if result:
275
+ print(f" Review screenshot uploaded: {file_name}")
276
+ else:
277
+ print(" WARNING: Failed to commit screenshot upload", file=sys.stderr)
264
278
 
265
- def main() -> None:
266
- if len(sys.argv) < 2:
267
- print(f"Usage: {sys.argv[0]} <path/to/iap_config.json>", file=sys.stderr)
268
- sys.exit(1)
269
279
 
270
- # Validate required environment variables
280
+ def validate_env() -> tuple:
281
+ """Validate required environment variables. Returns (key_id, issuer_id, private_key, bundle_id, project_root)."""
271
282
  required_vars = [
272
- "APP_STORE_CONNECT_KEY_IDENTIFIER", "APP_STORE_CONNECT_ISSUER_ID",
273
- "APP_STORE_CONNECT_PRIVATE_KEY", "BUNDLE_ID", "PROJECT_ROOT",
283
+ "APP_STORE_CONNECT_KEY_IDENTIFIER",
284
+ "APP_STORE_CONNECT_ISSUER_ID",
285
+ "APP_STORE_CONNECT_PRIVATE_KEY",
286
+ "BUNDLE_ID",
287
+ "PROJECT_ROOT",
274
288
  ]
275
- env = {var: os.environ.get(var, "") for var in required_vars}
276
- missing = [var for var, val in env.items() if not val]
289
+ values = {var: os.environ.get(var, "") for var in required_vars}
290
+ missing = [var for var, val in values.items() if not val]
277
291
  if missing:
278
- print(f"ERROR: Missing env vars: {', '.join(missing)}", file=sys.stderr)
292
+ print(f"ERROR: Missing required environment variables: {', '.join(missing)}", file=sys.stderr)
279
293
  sys.exit(1)
280
- key_id, issuer_id, private_key, bundle_id, project_root = env.values()
294
+ return tuple(values.values())
281
295
 
282
- # Load IAP config
283
- config_path = sys.argv[1]
296
+
297
+ def load_iap_config(config_path: str) -> dict:
298
+ """Load and validate the IAP config file."""
284
299
  if not os.path.isfile(config_path):
285
300
  print(f"ERROR: IAP config file not found: {config_path}", file=sys.stderr)
286
301
  sys.exit(1)
302
+
287
303
  with open(config_path, "r", encoding="utf-8") as f:
288
304
  config = json.load(f)
305
+
289
306
  if not config.get("subscription_groups"):
290
307
  print("WARNING: No subscription_groups found in config", file=sys.stderr)
291
308
 
309
+ return config
310
+
311
+
312
+ def main() -> None:
313
+ if len(sys.argv) < 2:
314
+ print(f"Usage: {sys.argv[0]} <path/to/iap_config.json>", file=sys.stderr)
315
+ sys.exit(1)
316
+
317
+ config_path = sys.argv[1]
318
+ key_id, issuer_id, private_key, bundle_id, project_root = validate_env()
319
+
292
320
  token = get_jwt_token(key_id, issuer_id, private_key)
293
321
  headers = {
294
322
  "Authorization": f"Bearer {token}",
295
323
  "Content-Type": "application/json",
296
324
  }
297
325
 
326
+ config = load_iap_config(config_path)
298
327
  app_id = get_app_id(headers, bundle_id)
299
328
  print(f"App ID: {app_id} (Bundle: {bundle_id})")
300
329
 
301
330
  existing_groups = list_subscription_groups(headers, app_id)
302
331
  results = []
332
+
303
333
  for group_config in config.get("subscription_groups", []):
304
334
  result = sync_subscription_group(
305
335
  headers, app_id, group_config, existing_groups, project_root,