@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.
- package/.claude-plugin/marketplace.json +8 -3
- package/package.json +1 -1
- package/plugins/store-automator/.claude-plugin/plugin.json +9 -2
- package/templates/fastlane/ios/Fastfile.template +0 -1
- package/templates/scripts/asc_subscription_setup.py +91 -48
- package/templates/scripts/sync_iap_ios.py +66 -36
- package/templates/scripts/__pycache__/asc_iap_api.cpython-311.pyc +0 -0
- package/templates/scripts/__pycache__/asc_subscription_setup.cpython-311.pyc +0 -0
- package/templates/scripts/__pycache__/gplay_iap_api.cpython-311.pyc +0 -0
- package/templates/scripts/__pycache__/sync_iap_android.cpython-311.pyc +0 -0
- package/templates/scripts/__pycache__/sync_iap_ios.cpython-311.pyc +0 -0
|
@@ -5,15 +5,20 @@
|
|
|
5
5
|
},
|
|
6
6
|
"metadata": {
|
|
7
7
|
"description": "App Store & Google Play automation for Flutter apps",
|
|
8
|
-
"version": "0.10.
|
|
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.
|
|
16
|
-
"keywords": [
|
|
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,9 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "store-automator",
|
|
3
|
-
"version": "0.10.
|
|
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": [
|
|
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 = [
|
|
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={
|
|
73
|
-
"
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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={
|
|
166
|
-
"
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
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
|
|
209
|
-
resource_type = "subscriptionAppStoreReviewScreenshots"
|
|
227
|
+
"""Reserve a review screenshot upload slot for a subscription."""
|
|
210
228
|
resp = requests.post(
|
|
211
|
-
f"{BASE_URL}/
|
|
212
|
-
json={
|
|
213
|
-
"
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
234
|
-
if not
|
|
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 {
|
|
237
|
-
f"HTTP {
|
|
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
|
|
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}/
|
|
243
|
-
json={
|
|
244
|
-
"
|
|
245
|
-
|
|
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
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
251
|
-
|
|
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
|
-
|
|
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",
|
|
273
|
-
"
|
|
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
|
-
|
|
276
|
-
missing = [var for var, val in
|
|
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
|
|
292
|
+
print(f"ERROR: Missing required environment variables: {', '.join(missing)}", file=sys.stderr)
|
|
279
293
|
sys.exit(1)
|
|
280
|
-
|
|
294
|
+
return tuple(values.values())
|
|
281
295
|
|
|
282
|
-
|
|
283
|
-
|
|
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,
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|