@daemux/store-automator 0.10.34 → 0.10.36

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.34"
8
+ "version": "0.10.36"
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.34",
15
+ "version": "0.10.36",
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.34",
3
+ "version": "0.10.36",
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.34",
3
+ "version": "0.10.36",
4
4
  "description": "App Store & Google Play automation agents for Flutter app publishing",
5
5
  "author": {
6
6
  "name": "Daemux"
@@ -66,27 +66,17 @@ 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 = [
70
- {"type": "territories", "id": tid} for tid in territory_ids
71
- ]
69
+ territory_data = [{"type": "territories", "id": tid} for tid in territory_ids]
72
70
  resp = requests.post(
73
71
  f"{BASE_URL}/subscriptionAvailabilities",
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
- },
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
+ }},
90
80
  headers=headers,
91
81
  timeout=TIMEOUT,
92
82
  )
@@ -172,26 +162,16 @@ def create_subscription_price(
172
162
  """Create a price entry for a subscription using a price point ID."""
173
163
  resp = requests.post(
174
164
  f"{BASE_URL}/subscriptionPrices",
175
- json={
176
- "data": {
177
- "type": "subscriptionPrices",
178
- "attributes": {
179
- "startDate": start_date,
180
- "preserveCurrentPrice": False,
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},
181
172
  },
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
- },
173
+ },
174
+ }},
195
175
  headers=headers,
196
176
  timeout=TIMEOUT,
197
177
  )
@@ -221,72 +201,49 @@ def get_review_screenshot(headers: dict, sub_id: str) -> dict | None:
221
201
  return resp.json().get("data")
222
202
 
223
203
 
224
- def reserve_review_screenshot(
225
- headers: dict, sub_id: str, file_name: str, file_size: int,
204
+ def upload_review_screenshot(
205
+ headers: dict, sub_id: str,
206
+ file_name: str, file_data: bytes, md5_checksum: str,
226
207
  ) -> dict | None:
227
- """Reserve a review screenshot upload slot for a subscription."""
208
+ """Reserve, upload chunks, and commit a review screenshot in one operation."""
209
+ resource_type = "subscriptionAppStoreReviewScreenshots"
228
210
  resp = requests.post(
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
- },
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
+ }},
244
219
  headers=headers,
245
220
  timeout=TIMEOUT,
246
221
  )
247
222
  if not resp.ok:
248
223
  print_api_errors(resp, f"reserve screenshot for subscription {sub_id}")
249
224
  return None
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]
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"]]
262
232
  op_headers = {h["name"]: h["value"] for h in op.get("requestHeaders", [])}
263
- resp = requests.put(url, headers=op_headers, data=chunk, timeout=TIMEOUT)
264
- if not resp.ok:
233
+ chunk_resp = requests.put(op["url"], headers=op_headers, data=chunk, timeout=TIMEOUT)
234
+ if not chunk_resp.ok:
265
235
  print(
266
- f"ERROR (upload chunk at offset {offset}): "
267
- f"HTTP {resp.status_code} - {resp.text[:200]}",
236
+ f"ERROR (upload chunk at offset {op['offset']}): "
237
+ f"HTTP {chunk_resp.status_code} - {chunk_resp.text[:200]}",
268
238
  file=sys.stderr,
269
239
  )
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."""
240
+ return None
278
241
  resp = requests.patch(
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
- },
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
+ }},
290
247
  headers=headers,
291
248
  timeout=TIMEOUT,
292
249
  )
@@ -21,11 +21,14 @@ if [ ! -f "$IAP_CONFIG" ]; then
21
21
  ci_skip "No Android IAP config file found"
22
22
  fi
23
23
 
24
- # --- Hash-based change detection ---
24
+ # --- Hash-based change detection (config + Python scripts) ---
25
25
  STATE_DIR="$PROJECT_ROOT/.ci-state"
26
26
  mkdir -p "$STATE_DIR"
27
27
 
28
- HASH=$(shasum -a 256 "$IAP_CONFIG" | cut -d' ' -f1)
28
+ HASH=$(cat "$IAP_CONFIG" \
29
+ "$PROJECT_ROOT/scripts/sync_iap_android.py" \
30
+ "$PROJECT_ROOT/scripts/gplay_iap_api.py" \
31
+ | shasum -a 256 | cut -d' ' -f1)
29
32
  STATE_FILE="$STATE_DIR/android-iap-hash"
30
33
 
31
34
  if [ -f "$STATE_FILE" ]; then
@@ -16,8 +16,12 @@ if [ ! -f "$IAP_CONFIG" ]; then
16
16
  ci_skip "No iOS IAP config file found"
17
17
  fi
18
18
 
19
- # --- Hash-based change detection ---
20
- CURRENT_HASH=$(shasum -a 256 "$IAP_CONFIG" | cut -d' ' -f1)
19
+ # --- Hash-based change detection (config + Python scripts) ---
20
+ CURRENT_HASH=$(cat "$IAP_CONFIG" \
21
+ "$PROJECT_ROOT/scripts/sync_iap_ios.py" \
22
+ "$PROJECT_ROOT/scripts/asc_subscription_setup.py" \
23
+ "$PROJECT_ROOT/scripts/asc_iap_api.py" \
24
+ | shasum -a 256 | cut -d' ' -f1)
21
25
 
22
26
  STATE_DIR="$PROJECT_ROOT/.ci-state"
23
27
  mkdir -p "$STATE_DIR"
@@ -35,7 +35,6 @@ from asc_iap_api import (
35
35
  update_localization,
36
36
  )
37
37
  from asc_subscription_setup import (
38
- commit_review_screenshot,
39
38
  create_subscription_availability,
40
39
  create_subscription_price,
41
40
  find_price_point_by_amount,
@@ -44,8 +43,7 @@ from asc_subscription_setup import (
44
43
  get_subscription_availability,
45
44
  get_subscription_prices,
46
45
  list_all_territory_ids,
47
- reserve_review_screenshot,
48
- upload_screenshot_chunks,
46
+ upload_review_screenshot,
49
47
  )
50
48
 
51
49
  CURRENCY_TO_TERRITORY = {
@@ -225,27 +223,12 @@ def _sync_review_screenshot(
225
223
  return
226
224
 
227
225
  full_path = os.path.join(project_root, screenshot_path)
228
-
229
- # Fallback: if configured path doesn't exist, pick first iPhone screenshot
230
226
  if not os.path.isfile(full_path):
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)
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)
248
230
  return
231
+ print(f" Using fallback screenshot: {os.path.basename(full_path)}")
249
232
 
250
233
  existing = get_review_screenshot(headers, sub_id)
251
234
  if existing:
@@ -257,79 +240,66 @@ def _sync_review_screenshot(
257
240
 
258
241
  file_name = os.path.basename(full_path)
259
242
  md5_checksum = hashlib.md5(file_data).hexdigest()
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
265
-
266
- screenshot_id = reservation["id"]
267
- upload_ops = reservation["attributes"].get("uploadOperations", [])
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)
243
+ result = upload_review_screenshot(headers, sub_id, file_name, file_data, md5_checksum)
274
244
  if result:
275
245
  print(f" Review screenshot uploaded: {file_name}")
276
246
  else:
277
- print(" WARNING: Failed to commit screenshot upload", file=sys.stderr)
247
+ print(" WARNING: Failed to upload screenshot", file=sys.stderr)
278
248
 
279
249
 
280
- def validate_env() -> tuple:
281
- """Validate required environment variables. Returns (key_id, issuer_id, private_key, bundle_id, project_root)."""
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
263
+
264
+
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
+
270
+ # Validate required environment variables
282
271
  required_vars = [
283
- "APP_STORE_CONNECT_KEY_IDENTIFIER",
284
- "APP_STORE_CONNECT_ISSUER_ID",
285
- "APP_STORE_CONNECT_PRIVATE_KEY",
286
- "BUNDLE_ID",
287
- "PROJECT_ROOT",
272
+ "APP_STORE_CONNECT_KEY_IDENTIFIER", "APP_STORE_CONNECT_ISSUER_ID",
273
+ "APP_STORE_CONNECT_PRIVATE_KEY", "BUNDLE_ID", "PROJECT_ROOT",
288
274
  ]
289
- values = {var: os.environ.get(var, "") for var in required_vars}
290
- missing = [var for var, val in values.items() if not val]
275
+ env = {var: os.environ.get(var, "") for var in required_vars}
276
+ missing = [var for var, val in env.items() if not val]
291
277
  if missing:
292
- print(f"ERROR: Missing required environment variables: {', '.join(missing)}", file=sys.stderr)
278
+ print(f"ERROR: Missing env vars: {', '.join(missing)}", file=sys.stderr)
293
279
  sys.exit(1)
294
- return tuple(values.values())
280
+ key_id, issuer_id, private_key, bundle_id, project_root = env.values()
295
281
 
296
-
297
- def load_iap_config(config_path: str) -> dict:
298
- """Load and validate the IAP config file."""
282
+ # Load IAP config
283
+ config_path = sys.argv[1]
299
284
  if not os.path.isfile(config_path):
300
285
  print(f"ERROR: IAP config file not found: {config_path}", file=sys.stderr)
301
286
  sys.exit(1)
302
-
303
287
  with open(config_path, "r", encoding="utf-8") as f:
304
288
  config = json.load(f)
305
-
306
289
  if not config.get("subscription_groups"):
307
290
  print("WARNING: No subscription_groups found in config", file=sys.stderr)
308
291
 
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
-
320
292
  token = get_jwt_token(key_id, issuer_id, private_key)
321
293
  headers = {
322
294
  "Authorization": f"Bearer {token}",
323
295
  "Content-Type": "application/json",
324
296
  }
325
297
 
326
- config = load_iap_config(config_path)
327
298
  app_id = get_app_id(headers, bundle_id)
328
299
  print(f"App ID: {app_id} (Bundle: {bundle_id})")
329
300
 
330
301
  existing_groups = list_subscription_groups(headers, app_id)
331
302
  results = []
332
-
333
303
  for group_config in config.get("subscription_groups", []):
334
304
  result = sync_subscription_group(
335
305
  headers, app_id, group_config, existing_groups, project_root,