@nomac/cli 0.2.3 → 0.2.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nomac/cli",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Ship iOS apps to TestFlight and the App Store without a Mac — built for agents.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
package/skill/SKILL.md CHANGED
@@ -12,15 +12,15 @@ description: >
12
12
  # nomac — ship iOS without a Mac
13
13
 
14
14
  nomac is the hands; you are the brain. It builds, signs, uploads, lints for
15
- review-readiness, writes store metadata you author, and submits for review —
15
+ review-readiness, writes store metadata, manages pricing/products and submits for review —
16
16
  via MCP tools (preferred) or the `nomac` CLI.
17
17
 
18
18
  ## Setup check
19
19
 
20
20
  1. MCP tools available (`connect_status`, `push_project`, `build`, …)? Use them.
21
- 2. Otherwise: `npx @nomac/cli whoami`. If not logged in, ask the human for an API
22
- key from nomac.app → API keys, then `npx @nomac/cli login <nmk_…>` and
23
- `claude mcp add nomac -- npx @nomac/cli mcp`.
21
+ 2. Otherwise: `npx @nomac/cli whoami`. If not logged in, run
22
+ `npx @nomac/cli login`; give the human the device URL and code to approve.
23
+ Then `claude mcp add nomac -- npx @nomac/cli mcp`.
24
24
  3. `connect_status` must show a healthy Apple connection. If not, the human
25
25
  connects at the dashboard (60 seconds, one .p8 upload). Don't guess —
26
26
  verify, then proceed.
@@ -50,8 +50,11 @@ status # poll ~30s until ready | failed
50
50
  (in the schema). It runs in the background: poll `get_screenshot_upload`
51
51
  until complete before publishing. A rolled-back or paused replacement
52
52
  needs the returned recovery instructions before starting another upload.
53
- 3. `publish` (no confirm) staged dry-run; fix any Apple blockers it returns.
54
- 4. Ask the human explicitly before `publish confirm=true` it's irreversible.
53
+ 3. `publish` without confirm stages real Apple drafts/build attachments but
54
+ does not submit. Fix the returned blockers; retain the exact build/submission IDs.
55
+ 4. `publish confirm=true` sends the app to Apple. Ensure the human authorized
56
+ this app/version; honor authorization already given. New versions use manual
57
+ release, while existing versions retain their Apple release policy.
55
58
 
56
59
  If metadata or publish returns pending, keep its `request_key` and repeat
57
60
  the original arguments with that key. `get_store_operation` recovers the key
@@ -60,6 +63,98 @@ do not use one to bypass an unconfirmed Apple request. Staging and confirmation
60
63
  remain separate invocations. For screenshots, keep the original `request_key`
61
64
  too, and use `get_screenshot_upload` to find or poll the existing operation.
62
65
 
66
+ ## Fix review rejections and release
67
+
68
+ - `get_review` lists live Apple submissions, including ones created outside
69
+ nomac. Pass `asc_submission_id` for all item states and exact version/build IDs.
70
+ - Apple does not expose App Review messages through its public API. Use the
71
+ returned App Store Connect handoff to have the human provide the actual text.
72
+ Never invent a rejection reason from a status code or public customer review.
73
+ - Fix metadata/screenshots on the exact `version_string`. A binary change needs
74
+ push/build, then `publish` with the exact `build_id` and `asc_submission_id`.
75
+ `resolve_rejection:true` explicitly asserts that this version's issue is fixed.
76
+ - `prepare_review_reply` prepares your authored text but always returns `sent:false`.
77
+ The human sends it and any message attachments in App Store Connect. Changing
78
+ review_contact.notes is not a reply; sending a reply is not resubmission.
79
+ - `update_review` supports `resolve_item`, `remove_item`, `cancel`, `resubmit`,
80
+ and `release`. Preview without confirm; confirm the exact requested action.
81
+ Fix issues before marking items resolved. Every rejected item must be resolved
82
+ or removed before resubmitting. Removed items cannot be added back to that submission.
83
+ - Poll `get_review`; approval and release are separate. Release requires an
84
+ app-version item in Pending Developer Release. Edit subscription/IAP products,
85
+ events and custom pages through the store catalog below. Apple/account/legal
86
+ decisions still require the relevant Apple workflow.
87
+
88
+ ## External TestFlight
89
+
90
+ `ready` from a nomac build means internal TestFlight readiness. External testing
91
+ requires its own review and distribution:
92
+
93
+ 1. `get_testflight` lists groups, test information and recent Apple builds. Use
94
+ `asc_build_id` for a specific build's beta-review/external state, or `group_id`
95
+ to list testers. Never infer external approval from internal readiness.
96
+ 2. `manage_testflight action=metadata` sets app_info (description/feedbackEmail/URLs),
97
+ review_contact (Apple contact/demo fields), locale, and build whats_new.
98
+ 3. Select/create an external group. `invite_tester`/`remove_tester` use an explicit
99
+ email/tester ID and group_id. `update_group` can enable an optional public link.
100
+ 4. `distribute` requires asc_build_id and explicit group_ids. It submits beta
101
+ review if needed, then assigns those groups. auto_notify defaults false;
102
+ `notify` starts testing after approval. `remove_build` affects one group;
103
+ `expire` stops testing for the build across all groups.
104
+ 5. All actions preview without confirm and apply with confirm:true. Pending
105
+ requests use their original key/body; get_store_operation recovers them.
106
+ 6. Relay an accurate human-provided uses_non_exempt_encryption with action=encryption
107
+ when appropriate. Never guess. Encryption declarations/documents use the
108
+ store catalog; rejected beta messages require App Store Connect. Fix the cause
109
+ before resubmitting a rejected binary.
110
+
111
+ ## Pricing, products, customer reviews and the wider store API
112
+
113
+ 1. `get_store_capabilities` lists operations by category/search with next_offset.
114
+ Pass `operation` to get that operation's exact Apple request/query schema.
115
+ Categories: listing, releases, pricing, purchases, subscriptions, reviews,
116
+ marketing, assets, testflight, analytics, compliance, webhooks.
117
+ 2. `read_store` executes listed GET operations. Begin with an `apps_*` relationship
118
+ and `scope:[]` for the linked app. Every child has a reusable `scope`; copy it
119
+ into the next read/write. Keep the query when following `next_cursor`.
120
+ 3. `update_store` takes the operation, scope and exact JSON:API `body`. For creation,
121
+ scope the parent. Every existing relationship ID needs its own verified scope
122
+ in `references`; the target and its ancestors are already verified. Territories
123
+ and categories are global. Inline creates use local IDs such as `${price1}`.
124
+ 4. Preview without confirm. `confirm:true` applies real paid prices, availability,
125
+ products/offers, public responses, release settings or event subscriptions.
126
+ Honor the human's existing authorization; do not ask again for authorized work.
127
+ Do not invent prices, legal answers or factual claims in public replies.
128
+ 5. `customerReviewResponses_createInstance` replies to a public customer review.
129
+ It does not send a message to Apple reviewers. Read their actual messages in
130
+ App Store Connect; `prepare_review_reply` only drafts the response.
131
+ 6. Product management covers subscriptions/IAP versions, localization, prices,
132
+ availability, grace periods and offers. First IAP approval may require the
133
+ app version submission. Draft review submission items can attach products;
134
+ use `publish` / `update_review` to submit, resolve, resubmit and release.
135
+ 7. Events, custom pages, experiments, accessibility and phased releases use their
136
+ catalog operations. Apple still validates account permissions and valid states.
137
+ Analytics report segments supply download URLs; offer-code CSV exports return
138
+ bounded base64 downloads. Webhooks send app events to an owner-selected receiver;
139
+ that receiver is responsible for running an agent when events arrive.
140
+
141
+ `upload_store_asset` handles new Apple file resources: previews, screenshot files,
142
+ purchase images, supplemental review attachments and encryption documents. Choose
143
+ the asset create operation, provide its parent relationship/body, and pass a local
144
+ `file_path` (CLI, up to 500 MiB) or `data_base64` (hosted, up to 2 MiB). Apple also
145
+ has asset-specific limits. The tool reserves, uploads each requested byte range,
146
+ then commits its checksum. Keep the same request_key and original bytes to resume.
147
+ `uploaded:true` is not processing success: poll `read_store` until
148
+ `assetDeliveryState.state` is `COMPLETE`. FAILED requires addressing Apple's errors.
149
+ This adds a new file; use `upload_screenshots` for managed screenshot replacement.
150
+
151
+ Keep pending write keys and unchanged bodies. `get_store_operation` exposes captured
152
+ targets and receipts. Unknown Apple outcomes remain pending and block other writes
153
+ to the app until reconciled; never use a fresh key to bypass one. Corrected inputs
154
+ or a new intentional action require a fresh key. Correspondence, appeals, App Privacy
155
+ questionnaire answers and account-wide financial/admin APIs are not exposed.
156
+ This catalog does not run simulators or Xcode Cloud jobs.
157
+
63
158
  ## When stuck
64
159
 
65
160
  `report_issue` with the failing `ref_id` files a support ticket with full
@@ -0,0 +1,631 @@
1
+ {
2
+ "get_store_capabilities": {
3
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
4
+ "type": "object",
5
+ "properties": {
6
+ "category": {
7
+ "type": "string",
8
+ "maxLength": 80
9
+ },
10
+ "operation": {
11
+ "type": "string",
12
+ "maxLength": 160
13
+ },
14
+ "search": {
15
+ "type": "string",
16
+ "maxLength": 100
17
+ },
18
+ "offset": {
19
+ "type": "integer",
20
+ "minimum": 0,
21
+ "maximum": 1000
22
+ }
23
+ },
24
+ "additionalProperties": false
25
+ },
26
+ "read_store": {
27
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
28
+ "type": "object",
29
+ "properties": {
30
+ "operation": {
31
+ "type": "string",
32
+ "minLength": 1,
33
+ "maxLength": 160
34
+ },
35
+ "scope": {
36
+ "maxItems": 8,
37
+ "type": "array",
38
+ "items": {
39
+ "type": "object",
40
+ "properties": {
41
+ "relationship": {
42
+ "type": "string",
43
+ "maxLength": 100,
44
+ "pattern": "^[A-Za-z][A-Za-z0-9]*$"
45
+ },
46
+ "id": {
47
+ "type": "string",
48
+ "minLength": 1,
49
+ "maxLength": 512
50
+ },
51
+ "query": {
52
+ "type": "object",
53
+ "propertyNames": {
54
+ "type": "string",
55
+ "maxLength": 120
56
+ },
57
+ "additionalProperties": {
58
+ "anyOf": [
59
+ {
60
+ "type": "string",
61
+ "maxLength": 4096
62
+ },
63
+ {
64
+ "type": "number"
65
+ },
66
+ {
67
+ "type": "boolean"
68
+ },
69
+ {
70
+ "maxItems": 200,
71
+ "type": "array",
72
+ "items": {
73
+ "type": "string",
74
+ "maxLength": 1024
75
+ }
76
+ }
77
+ ]
78
+ }
79
+ }
80
+ },
81
+ "required": [
82
+ "relationship",
83
+ "id"
84
+ ],
85
+ "additionalProperties": false
86
+ }
87
+ },
88
+ "query": {
89
+ "type": "object",
90
+ "propertyNames": {
91
+ "type": "string",
92
+ "maxLength": 120
93
+ },
94
+ "additionalProperties": {
95
+ "anyOf": [
96
+ {
97
+ "type": "string",
98
+ "maxLength": 4096
99
+ },
100
+ {
101
+ "type": "number"
102
+ },
103
+ {
104
+ "type": "boolean"
105
+ },
106
+ {
107
+ "maxItems": 200,
108
+ "type": "array",
109
+ "items": {
110
+ "type": "string",
111
+ "maxLength": 1024
112
+ }
113
+ }
114
+ ]
115
+ }
116
+ },
117
+ "cursor": {
118
+ "type": "string",
119
+ "maxLength": 4096
120
+ },
121
+ "resource_id": {
122
+ "type": "string",
123
+ "minLength": 1,
124
+ "maxLength": 512
125
+ }
126
+ },
127
+ "required": [
128
+ "operation"
129
+ ],
130
+ "additionalProperties": false
131
+ },
132
+ "update_store": {
133
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
134
+ "type": "object",
135
+ "properties": {
136
+ "operation": {
137
+ "type": "string",
138
+ "minLength": 1,
139
+ "maxLength": 160
140
+ },
141
+ "scope": {
142
+ "maxItems": 8,
143
+ "type": "array",
144
+ "items": {
145
+ "type": "object",
146
+ "properties": {
147
+ "relationship": {
148
+ "type": "string",
149
+ "maxLength": 100,
150
+ "pattern": "^[A-Za-z][A-Za-z0-9]*$"
151
+ },
152
+ "id": {
153
+ "type": "string",
154
+ "minLength": 1,
155
+ "maxLength": 512
156
+ },
157
+ "query": {
158
+ "type": "object",
159
+ "propertyNames": {
160
+ "type": "string",
161
+ "maxLength": 120
162
+ },
163
+ "additionalProperties": {
164
+ "anyOf": [
165
+ {
166
+ "type": "string",
167
+ "maxLength": 4096
168
+ },
169
+ {
170
+ "type": "number"
171
+ },
172
+ {
173
+ "type": "boolean"
174
+ },
175
+ {
176
+ "maxItems": 200,
177
+ "type": "array",
178
+ "items": {
179
+ "type": "string",
180
+ "maxLength": 1024
181
+ }
182
+ }
183
+ ]
184
+ }
185
+ }
186
+ },
187
+ "required": [
188
+ "relationship",
189
+ "id"
190
+ ],
191
+ "additionalProperties": false
192
+ }
193
+ },
194
+ "references": {
195
+ "maxItems": 30,
196
+ "type": "array",
197
+ "items": {
198
+ "maxItems": 8,
199
+ "type": "array",
200
+ "items": {
201
+ "type": "object",
202
+ "properties": {
203
+ "relationship": {
204
+ "type": "string",
205
+ "maxLength": 100,
206
+ "pattern": "^[A-Za-z][A-Za-z0-9]*$"
207
+ },
208
+ "id": {
209
+ "type": "string",
210
+ "minLength": 1,
211
+ "maxLength": 512
212
+ },
213
+ "query": {
214
+ "type": "object",
215
+ "propertyNames": {
216
+ "type": "string",
217
+ "maxLength": 120
218
+ },
219
+ "additionalProperties": {
220
+ "anyOf": [
221
+ {
222
+ "type": "string",
223
+ "maxLength": 4096
224
+ },
225
+ {
226
+ "type": "number"
227
+ },
228
+ {
229
+ "type": "boolean"
230
+ },
231
+ {
232
+ "maxItems": 200,
233
+ "type": "array",
234
+ "items": {
235
+ "type": "string",
236
+ "maxLength": 1024
237
+ }
238
+ }
239
+ ]
240
+ }
241
+ }
242
+ },
243
+ "required": [
244
+ "relationship",
245
+ "id"
246
+ ],
247
+ "additionalProperties": false
248
+ }
249
+ }
250
+ },
251
+ "body": {
252
+ "type": "object",
253
+ "propertyNames": {
254
+ "type": "string"
255
+ },
256
+ "additionalProperties": {}
257
+ },
258
+ "confirm": {
259
+ "type": "boolean"
260
+ },
261
+ "asset_sha256": {
262
+ "description": "Set by upload_store_asset for exact file-reservation recovery.",
263
+ "type": "string",
264
+ "pattern": "^[a-f0-9]{64}$"
265
+ }
266
+ },
267
+ "required": [
268
+ "operation"
269
+ ],
270
+ "additionalProperties": false
271
+ },
272
+ "upload_store_asset": {
273
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
274
+ "type": "object",
275
+ "properties": {
276
+ "operation": {
277
+ "type": "string",
278
+ "minLength": 1,
279
+ "maxLength": 160
280
+ },
281
+ "scope": {
282
+ "maxItems": 8,
283
+ "type": "array",
284
+ "items": {
285
+ "type": "object",
286
+ "properties": {
287
+ "relationship": {
288
+ "type": "string",
289
+ "maxLength": 100,
290
+ "pattern": "^[A-Za-z][A-Za-z0-9]*$"
291
+ },
292
+ "id": {
293
+ "type": "string",
294
+ "minLength": 1,
295
+ "maxLength": 512
296
+ },
297
+ "query": {
298
+ "type": "object",
299
+ "propertyNames": {
300
+ "type": "string",
301
+ "maxLength": 120
302
+ },
303
+ "additionalProperties": {
304
+ "anyOf": [
305
+ {
306
+ "type": "string",
307
+ "maxLength": 4096
308
+ },
309
+ {
310
+ "type": "number"
311
+ },
312
+ {
313
+ "type": "boolean"
314
+ },
315
+ {
316
+ "maxItems": 200,
317
+ "type": "array",
318
+ "items": {
319
+ "type": "string",
320
+ "maxLength": 1024
321
+ }
322
+ }
323
+ ]
324
+ }
325
+ }
326
+ },
327
+ "required": [
328
+ "relationship",
329
+ "id"
330
+ ],
331
+ "additionalProperties": false
332
+ }
333
+ },
334
+ "references": {
335
+ "maxItems": 30,
336
+ "type": "array",
337
+ "items": {
338
+ "maxItems": 8,
339
+ "type": "array",
340
+ "items": {
341
+ "type": "object",
342
+ "properties": {
343
+ "relationship": {
344
+ "type": "string",
345
+ "maxLength": 100,
346
+ "pattern": "^[A-Za-z][A-Za-z0-9]*$"
347
+ },
348
+ "id": {
349
+ "type": "string",
350
+ "minLength": 1,
351
+ "maxLength": 512
352
+ },
353
+ "query": {
354
+ "type": "object",
355
+ "propertyNames": {
356
+ "type": "string",
357
+ "maxLength": 120
358
+ },
359
+ "additionalProperties": {
360
+ "anyOf": [
361
+ {
362
+ "type": "string",
363
+ "maxLength": 4096
364
+ },
365
+ {
366
+ "type": "number"
367
+ },
368
+ {
369
+ "type": "boolean"
370
+ },
371
+ {
372
+ "maxItems": 200,
373
+ "type": "array",
374
+ "items": {
375
+ "type": "string",
376
+ "maxLength": 1024
377
+ }
378
+ }
379
+ ]
380
+ }
381
+ }
382
+ },
383
+ "required": [
384
+ "relationship",
385
+ "id"
386
+ ],
387
+ "additionalProperties": false
388
+ }
389
+ }
390
+ },
391
+ "body": {
392
+ "type": "object",
393
+ "propertyNames": {
394
+ "type": "string"
395
+ },
396
+ "additionalProperties": {}
397
+ },
398
+ "confirm": {
399
+ "type": "boolean"
400
+ },
401
+ "asset_sha256": {
402
+ "description": "Set by upload_store_asset for exact file-reservation recovery.",
403
+ "type": "string",
404
+ "pattern": "^[a-f0-9]{64}$"
405
+ },
406
+ "data_base64": {
407
+ "type": "string",
408
+ "minLength": 1,
409
+ "maxLength": 3145728
410
+ }
411
+ },
412
+ "required": [
413
+ "operation",
414
+ "data_base64"
415
+ ],
416
+ "additionalProperties": false
417
+ },
418
+ "update_review": {
419
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
420
+ "type": "object",
421
+ "properties": {
422
+ "asc_submission_id": {
423
+ "type": "string",
424
+ "minLength": 1
425
+ },
426
+ "action": {
427
+ "type": "string",
428
+ "enum": [
429
+ "resolve_item",
430
+ "remove_item",
431
+ "cancel",
432
+ "resubmit",
433
+ "release"
434
+ ]
435
+ },
436
+ "item_id": {
437
+ "type": "string",
438
+ "minLength": 1
439
+ },
440
+ "confirm": {
441
+ "default": false,
442
+ "type": "boolean"
443
+ },
444
+ "force": {
445
+ "default": false,
446
+ "type": "boolean"
447
+ }
448
+ },
449
+ "required": [
450
+ "asc_submission_id",
451
+ "action"
452
+ ],
453
+ "additionalProperties": false
454
+ },
455
+ "prepare_review_reply": {
456
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
457
+ "type": "object",
458
+ "properties": {
459
+ "asc_submission_id": {
460
+ "type": "string",
461
+ "minLength": 1
462
+ },
463
+ "reply": {
464
+ "type": "string",
465
+ "minLength": 1,
466
+ "maxLength": 4000
467
+ }
468
+ },
469
+ "required": [
470
+ "asc_submission_id",
471
+ "reply"
472
+ ],
473
+ "additionalProperties": false
474
+ },
475
+ "manage_testflight": {
476
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
477
+ "type": "object",
478
+ "properties": {
479
+ "action": {
480
+ "type": "string",
481
+ "enum": [
482
+ "metadata",
483
+ "create_group",
484
+ "update_group",
485
+ "invite_tester",
486
+ "remove_tester",
487
+ "remove_build",
488
+ "distribute",
489
+ "notify",
490
+ "expire",
491
+ "encryption"
492
+ ]
493
+ },
494
+ "asc_build_id": {
495
+ "type": "string",
496
+ "minLength": 1
497
+ },
498
+ "group_ids": {
499
+ "minItems": 1,
500
+ "maxItems": 100,
501
+ "type": "array",
502
+ "items": {
503
+ "type": "string",
504
+ "minLength": 1
505
+ }
506
+ },
507
+ "group_id": {
508
+ "type": "string",
509
+ "minLength": 1
510
+ },
511
+ "name": {
512
+ "type": "string",
513
+ "minLength": 1,
514
+ "maxLength": 100
515
+ },
516
+ "email": {
517
+ "type": "string",
518
+ "format": "email",
519
+ "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
520
+ },
521
+ "first_name": {
522
+ "type": "string",
523
+ "maxLength": 100
524
+ },
525
+ "last_name": {
526
+ "type": "string",
527
+ "maxLength": 100
528
+ },
529
+ "tester_id": {
530
+ "type": "string",
531
+ "minLength": 1
532
+ },
533
+ "public_link_enabled": {
534
+ "type": "boolean"
535
+ },
536
+ "public_link_limit": {
537
+ "type": "integer",
538
+ "minimum": 1,
539
+ "maximum": 10000
540
+ },
541
+ "auto_notify": {
542
+ "default": false,
543
+ "type": "boolean"
544
+ },
545
+ "uses_non_exempt_encryption": {
546
+ "type": "boolean"
547
+ },
548
+ "locale": {
549
+ "default": "en-US",
550
+ "type": "string",
551
+ "minLength": 2,
552
+ "maxLength": 35
553
+ },
554
+ "app_info": {
555
+ "type": "object",
556
+ "properties": {
557
+ "description": {
558
+ "type": "string",
559
+ "maxLength": 4000
560
+ },
561
+ "feedbackEmail": {
562
+ "type": "string",
563
+ "format": "email",
564
+ "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
565
+ },
566
+ "marketingUrl": {
567
+ "type": "string",
568
+ "maxLength": 255,
569
+ "format": "uri"
570
+ },
571
+ "privacyPolicyUrl": {
572
+ "type": "string",
573
+ "maxLength": 255,
574
+ "format": "uri"
575
+ }
576
+ },
577
+ "additionalProperties": false
578
+ },
579
+ "review_contact": {
580
+ "type": "object",
581
+ "properties": {
582
+ "contactFirstName": {
583
+ "type": "string",
584
+ "maxLength": 100
585
+ },
586
+ "contactLastName": {
587
+ "type": "string",
588
+ "maxLength": 100
589
+ },
590
+ "contactPhone": {
591
+ "type": "string",
592
+ "maxLength": 100
593
+ },
594
+ "contactEmail": {
595
+ "type": "string",
596
+ "format": "email",
597
+ "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
598
+ },
599
+ "demoAccountRequired": {
600
+ "type": "boolean"
601
+ },
602
+ "demoAccountName": {
603
+ "type": "string",
604
+ "maxLength": 100
605
+ },
606
+ "demoAccountPassword": {
607
+ "type": "string",
608
+ "maxLength": 100
609
+ },
610
+ "notes": {
611
+ "type": "string",
612
+ "maxLength": 4000
613
+ }
614
+ },
615
+ "additionalProperties": false
616
+ },
617
+ "whats_new": {
618
+ "type": "string",
619
+ "maxLength": 4000
620
+ },
621
+ "confirm": {
622
+ "default": false,
623
+ "type": "boolean"
624
+ }
625
+ },
626
+ "required": [
627
+ "action"
628
+ ],
629
+ "additionalProperties": false
630
+ }
631
+ }
package/src/mcp.mjs CHANGED
@@ -3,11 +3,14 @@
3
3
  // Works in every MCP client day one; the hosted transport is mcp.nomac.app.
4
4
 
5
5
  import { randomUUID } from "node:crypto";
6
+ import { readFileSync } from "node:fs";
7
+ const lifecycleSchemas = JSON.parse(readFileSync(new URL("./lifecycle-schemas.json", import.meta.url), "utf8"));
6
8
  import { createInterface } from "node:readline";
7
9
  import { api } from "./api.mjs";
8
10
  import { loadProjectState, saveProjectState } from "./config.mjs";
9
11
  import { packTarball } from "./pack.mjs";
10
12
  import { uploadSourceSnapshot } from "./upload.mjs";
13
+ import { uploadStoreAsset } from "./store-upload.mjs";
11
14
 
12
15
  const PROTOCOL_FALLBACK = "2025-06-18";
13
16
 
@@ -24,6 +27,29 @@ async function resolveProjectId(args) {
24
27
  }
25
28
 
26
29
  export const TOOLS = [
30
+ {
31
+ name: "upload_store_asset",
32
+ description: "Upload a new Apple screenshot, preview, purchase image, supplemental reviewer attachment or encryption document from file_path (up to 500 MiB, subject to Apple limits) or data_base64 (2 MiB). Discover the createInstance operation/body and parent scope with get_store_capabilities/read_store. Preview by default; confirm:true reserves, uploads and commits. Preserve request_key and identical bytes/body to resume. ready:true requires Apple processing COMPLETE. Message attachments require App Store Connect.",
33
+ inputSchema: { ...lifecycleSchemas.upload_store_asset, required: ["operation"], properties: { ...lifecycleSchemas.upload_store_asset.properties, project_id: { type: "string" }, file_path: { type: "string" }, request_key: { type: "string" } } },
34
+ handler: async args => text(await uploadStoreAsset({ ...args, project_id: await resolveProjectId(args) }, api)),
35
+ },
36
+ {
37
+ name: "get_store_capabilities",
38
+ description: "Discover useful App Store Connect operations: listing, paid pricing/availability, IAPs/subscriptions/offers, public customer-review replies, phased releases, marketing, files, analytics and webhooks. Pass category/search to list; pass operation for the exact Apple JSON:API request schema. Reviewer correspondence is a separate browser workflow.",
39
+ inputSchema: lifecycleSchemas.get_store_capabilities,
40
+ handler: async args => text(await api("GET", `/api/v1/store-capabilities?${new URLSearchParams(Object.entries(args ?? {}).filter(([, value]) => value !== undefined).map(([key, value]) => [key, String(value)]))}`)),
41
+ },
42
+ ...[
43
+ ["read_store", "read", "Read a discovered Apple GET operation. Start with an apps_* relationship and scope:[]; reuse the returned resource scopes. Paginate with next_cursor and unchanged query. Raw IDs are accepted only for global lookups. Reads live Apple state, file upload operations and analytics report URLs."],
44
+ ["update_store", "write", "Execute a discovered Apple write using its exact JSON:API body. Copy scope and relationship references from read_store. Preview by default; confirm:true changes real Apple resources. Use owner-authorized prices and attestations. On pending, retry identical arguments with request_key; get_store_operation recovers lost keys. Use publish/update_review/manage_testflight for their dedicated lifecycle actions."],
45
+ ].map(([name, route, description]) => ({
46
+ name, description,
47
+ inputSchema: { ...lifecycleSchemas[name], properties: { project_id: { type: "string" }, ...lifecycleSchemas[name].properties, ...(route === "write" ? { request_key: { type: "string" } } : {}) } },
48
+ handler: async args => {
49
+ const { project_id: _project, request_key, ...body } = args;
50
+ return text(await api("POST", `/api/v1/projects/${encodeURIComponent(await resolveProjectId(args))}/store/${route}`, { body, ...(route === "write" ? { idempotencyKey: request_key ?? `mcp-store-${randomUUID()}` } : {}) }));
51
+ },
52
+ })),
27
53
  {
28
54
  name: "connect_status",
29
55
  description:
@@ -236,18 +262,21 @@ export const TOOLS = [
236
262
  },
237
263
  {
238
264
  name: "get_store_operation",
239
- description: "Read saved publish/metadata requests and recover a lost response or request_key. Resume pending work by repeating the original publish/set_metadata arguments with its returned request_key. This status call performs no Apple writes.",
265
+ description: "Read saved publish/metadata/review/TestFlight requests and recover a lost response or request_key. Resume pending work by repeating the original tool arguments with its returned request_key. This status call performs no Apple writes.",
240
266
  inputSchema: { type: "object", properties: { project_id: { type: "string" }, request_key: { type: "string" } }, additionalProperties: false },
241
267
  handler: async args => text(await api("GET", `/api/v1/projects/${await resolveProjectId(args)}/operations${args?.request_key ? `?request_key=${encodeURIComponent(args.request_key)}` : ""}`)),
242
268
  },
243
269
  {
244
270
  name: "publish",
245
271
  description:
246
- "Submit for App Store review (3-step reviewSubmissions). IRREVERSIBLE once confirmed requires confirm:true; run once without confirm first to see the staged result + any Apple blockers. Costs nothing; the review decision takes ~1-3 days. On pending, repeat unchanged arguments with the returned request_key; get_store_operation recovers a lost response.",
272
+ "Submit for App Store review (3-step reviewSubmissions). Requires confirm:true with authorization for this app/version; run once without confirm first to see the staged result + any Apple blockers. Costs nothing; the review decision takes ~1-3 days. On pending, repeat unchanged arguments with the returned request_key; get_store_operation recovers a lost response.",
247
273
  inputSchema: {
248
274
  type: "object",
249
275
  properties: {
250
276
  project_id: { type: "string" },
277
+ build_id: { type: "string", description: "Exact nomac release build to attach" },
278
+ asc_submission_id: { type: "string", description: "Exact Apple submission from get_review" },
279
+ resolve_rejection: { type: "boolean", default: false, description: "Assert this version rejection was fixed; mark its item ready for review" },
251
280
  confirm: { type: "boolean", default: false },
252
281
  request_key: { type: "string", description: "Only supply to resume an interrupted invocation with its original unchanged arguments" },
253
282
  force: { type: "boolean", default: false, description: "override a red lint gate (not recommended)" },
@@ -264,12 +293,33 @@ export const TOOLS = [
264
293
  const idempotencyKey = args?.request_key ?? `mcp-pub-${project_id}-${mode}-${randomUUID()}`;
265
294
  return text(
266
295
  await api("POST", "/api/v1/publish", {
267
- body: { project_id, confirm: args?.confirm ?? false, force: args?.force ?? false },
296
+ body: { project_id, ...(args?.build_id ? { build_id: args.build_id } : {}), ...(args?.asc_submission_id ? { asc_submission_id: args.asc_submission_id } : {}), ...(args?.resolve_rejection !== undefined ? { resolve_rejection: args.resolve_rejection } : {}), confirm: args?.confirm ?? false, force: args?.force ?? false },
268
297
  idempotencyKey,
269
298
  }),
270
299
  );
271
300
  },
272
301
  },
302
+ ...[
303
+ ["get_review", "reviews", "asc_submission_id", "Read live App Review submissions including those created outside nomac. Pass asc_submission_id for all items, version/build/contact details. Reviewer messages require the App Store Connect handoff in the result."],
304
+ ["get_testflight", "testflight", "asc_build_id", "Read external/internal groups and test metadata. Pass asc_build_id for exact beta review and distribution state. Internal readiness does not imply external approval."],
305
+ ].map(([name, path, filter, description]) => ({
306
+ name, description,
307
+ inputSchema: { type: "object", properties: { project_id: { type: "string" }, [filter]: { type: "string" }, ...(name === "get_testflight" ? { group_id: { type: "string" } } : {}) }, additionalProperties: false },
308
+ handler: async args => text(await api("GET", `/api/v1/projects/${encodeURIComponent(await resolveProjectId(args))}/${path}${args?.[filter] || args?.group_id ? `?${new URLSearchParams({ ...(args?.[filter] ? { [filter]: args[filter] } : {}), ...(args?.group_id ? { group_id: args.group_id } : {}) })}` : ""}`)),
309
+ })),
310
+ ...[
311
+ ["update_review", "reviews", "Resolve a fixed rejected item, remove an item, cancel review, resubmit ready items or release an approved version. Use exact IDs from get_review. Preview without confirm; confirm:true performs the action. Removal cannot be undone in that submission. Fix actual issues before resolving. On pending, repeat unchanged arguments with request_key; get_store_operation recovers it."],
312
+ ["manage_testflight", "testflight", "Set beta metadata/contact/demo credentials, create/update external groups/public links, invite/remove an explicit tester, distribute a specific Apple build to explicit groups and Beta App Review, notify after approval, expire testing, or relay a human-provided encryption answer. Preview without confirm; confirm:true applies. auto_notify defaults false. Never guess attestations. On pending, repeat unchanged arguments with request_key."],
313
+ ["prepare_review_reply", "reviews/reply", "Prepare YOUR authored reply with an App Store Connect handoff. Always returns sent:false: Apple's public API cannot read or send reviewer messages. The human must paste and send it. This does not change reviewer notes or public customer reviews."],
314
+ ].map(([name, path, description]) => ({
315
+ name, description,
316
+ inputSchema: { ...lifecycleSchemas[name], properties: { project_id: { type: "string" }, ...lifecycleSchemas[name].properties, ...(name !== "prepare_review_reply" ? { request_key: { type: "string" } } : {}) } },
317
+ handler: async args => {
318
+ const projectId = await resolveProjectId(args);
319
+ const { project_id: _project, request_key, ...body } = args;
320
+ return text(await api("POST", `/api/v1/projects/${encodeURIComponent(projectId)}/${path}`, { body, idempotencyKey: request_key ?? randomUUID() }));
321
+ },
322
+ })),
273
323
  {
274
324
  name: "get_feedback",
275
325
  description:
@@ -0,0 +1,127 @@
1
+ // Shared by the hosted upload endpoint and the published CLI (copied by the
2
+ // schema generator). Provider upload URLs come only from an authenticated,
3
+ // app-scoped read_store response; Nomac credentials never go to those URLs.
4
+ import { createHash, randomUUID } from 'node:crypto';
5
+ import { open } from 'node:fs/promises';
6
+ import { basename } from 'node:path';
7
+ import { Readable } from 'node:stream';
8
+
9
+ function unwrap(schema, components) {
10
+ if (schema?.$ref) return unwrap(components.schemas[schema.$ref.split('/').at(-1)], components);
11
+ if (schema?.anyOf) return unwrap(schema.anyOf.find(s => s.type !== 'null'), components);
12
+ return schema;
13
+ }
14
+ function fields(detail) {
15
+ const root = unwrap(detail.operation.body, detail.operation.components);
16
+ return unwrap(unwrap(root.properties.data, detail.operation.components).properties.attributes, detail.operation.components)?.properties ?? {};
17
+ }
18
+ const digest = (name, bytes) => createHash(name).update(bytes).digest('hex');
19
+
20
+ export async function uploadStoreAsset(args, call) {
21
+ const requestKey = args.request_key ?? `store-upload-${randomUUID()}`;
22
+ let handle, current, source, md5, sha256, size, filename, reservation;
23
+ let stage = 'prepare';
24
+ try {
25
+ if (Boolean(args.file_path) === Boolean(args.data_base64)) throw new Error('Supply exactly one local file_path (CLI) or data_base64 (hosted).');
26
+ if (!args.operation?.endsWith('_createInstance')) throw new Error('Choose an asset createInstance operation from get_store_capabilities.');
27
+ if (args.file_path) {
28
+ handle = await open(args.file_path, 'r');
29
+ current = await handle.stat({ bigint: true });
30
+ if (!current.isFile() || current.size < 1n || current.size > 500n * 1024n * 1024n) throw new Error('Upload a regular file between 1 byte and 500 MiB. Apple may impose a smaller asset-specific limit.');
31
+ size = Number(current.size); filename = basename(args.file_path);
32
+ const range = async function* (offset, length) {
33
+ for (let position = offset; position < offset + length;) {
34
+ const buffer = Buffer.allocUnsafe(Math.min(1024 * 1024, offset + length - position));
35
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
36
+ if (!bytesRead) throw new Error('The source file was truncated during upload.');
37
+ position += bytesRead;
38
+ yield buffer.subarray(0, bytesRead);
39
+ }
40
+ };
41
+ const sha = createHash('sha256'), checksum = createHash('md5');
42
+ for await (const bytes of range(0, size)) { sha.update(bytes); checksum.update(bytes); }
43
+ sha256 = sha.digest('hex'); md5 = checksum.digest('hex');
44
+ source = async (offset, length) => {
45
+ const latest = await handle.stat({ bigint: true });
46
+ if (['size', 'mtimeNs', 'ctimeNs', 'ino'].some(k => latest[k] !== current[k])) throw new Error('The source file changed. Resume with the original bytes or start a new upload.');
47
+ return Readable.from(range(offset, length));
48
+ };
49
+ } else {
50
+ if (typeof args.data_base64 !== 'string' || args.data_base64.length > 3 * 1024 * 1024 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(args.data_base64)) throw new Error('Provide canonical base64 for a file up to 2 MiB; use the CLI for larger files.');
51
+ const bytes = Buffer.from(args.data_base64, 'base64');
52
+ size = bytes.length;
53
+ if (!size || size > 2 * 1024 * 1024) throw new Error('Hosted inline uploads support files between 1 byte and 2 MiB. Use the CLI for larger files.');
54
+ md5 = digest('md5', bytes); sha256 = digest('sha256', bytes); filename = 'asset.bin';
55
+ source = async (offset, length) => bytes.subarray(offset, offset + length);
56
+ }
57
+ const detail = await call('GET', `/api/v1/store-capabilities?operation=${encodeURIComponent(args.operation)}`);
58
+ const createFields = fields(detail);
59
+ if (!createFields.fileName || !createFields.fileSize) throw new Error('This operation does not create an uploadable Apple file.');
60
+ const commitOperation = args.operation.replace(/_createInstance$/, '_updateInstance');
61
+ const commitDetail = await call('GET', `/api/v1/store-capabilities?operation=${encodeURIComponent(commitOperation)}`);
62
+ const commitFields = fields(commitDetail);
63
+ if (!commitFields.uploaded) throw new Error('This asset does not support the standard Apple upload commit operation.');
64
+ const body = structuredClone(args.body ?? {});
65
+ if (!body.data || !body.data.relationships) throw new Error('Supply the asset JSON:API body and its parent relationship from get_store_capabilities.');
66
+ body.data.attributes ??= {};
67
+ if (body.data.attributes.fileSize !== undefined && body.data.attributes.fileSize !== size) throw new Error('The declared fileSize does not match the source file.');
68
+ filename = basename(body.data.attributes.fileName || filename).normalize('NFC').replace(/[\r\n]/g, '_');
69
+ const tag = digest('sha256', `${args.project_id}\n${requestKey}\n${args.operation}\n${sha256}`).slice(0, 32);
70
+ body.data.attributes.fileName = `nomac-${tag}-${filename}`;
71
+ body.data.attributes.fileSize = size;
72
+ const base = `/api/v1/projects/${encodeURIComponent(args.project_id)}/store`;
73
+ const write = (payload, key) => call('POST', `${base}/write`, { body: payload, idempotencyKey: key });
74
+ stage = 'reserve';
75
+ reservation = await write({ operation: args.operation, scope: args.scope, references: args.references, body, ...(args.confirm ? { asset_sha256: sha256 } : {}), confirm: Boolean(args.confirm) }, args.confirm ? requestKey : `${requestKey}:preview`);
76
+ if (!args.confirm || reservation.state === 'pending') return { ...reservation, request_key: requestKey, stage: 'reserve', file_size: size, sha256 };
77
+ if (!reservation.resource?.id || !reservation.scope) throw new Error('Apple has not returned an app-owned file reservation. Resume this request key; do not create another file.');
78
+ const read = async () => call('POST', `${base}/read`, { body: { operation: args.operation.replace(/_createInstance$/, '_getInstance'), scope: reservation.scope } });
79
+ let state = await read();
80
+ let attributes = state.data?.attributes;
81
+ if (!attributes || attributes.fileSize !== size || attributes.fileName !== body.data.attributes.fileName) throw new Error('The reserved Apple file does not match this upload.');
82
+ stage = 'upload';
83
+ const completed = ['UPLOAD_COMPLETE', 'COMPLETE', 'FAILED'].includes(attributes.assetDeliveryState?.state);
84
+ if (!completed) {
85
+ const operations = attributes.uploadOperations;
86
+ if (!Array.isArray(operations) || !operations.length) throw new Error('Apple has not exposed upload operations yet. Retry this same upload.');
87
+ let end = 0;
88
+ for (const op of [...operations].sort((a, b) => a.offset - b.offset)) {
89
+ if (op.method !== 'PUT' || !Number.isSafeInteger(op.offset) || !Number.isSafeInteger(op.length) || op.length < 1 || op.offset !== end || op.offset + op.length > size) throw new Error('Apple returned invalid or incomplete upload byte ranges.');
90
+ const url = new URL(op.url);
91
+ if (url.protocol !== 'https:' || url.username || url.password || !['apple.com', 'icloud.com', 'mzstatic.com', 'amazonaws.com'].some(domain => url.hostname === domain || url.hostname.endsWith(`.${domain}`))) throw new Error('Apple returned an unexpected upload destination.');
92
+ end += op.length;
93
+ }
94
+ if (end !== size) throw new Error('Apple upload operations do not cover the complete source file.');
95
+ for (const op of operations) {
96
+ const headers = new Headers();
97
+ for (const header of op.requestHeaders ?? []) {
98
+ if (typeof header.name !== 'string' || typeof header.value !== 'string') throw new Error('Apple returned invalid upload headers.');
99
+ headers.set(header.name, header.value);
100
+ }
101
+ if (headers.has('content-length') && headers.get('content-length') !== String(op.length)) throw new Error('Apple returned a Content-Length that does not match the upload byte range.');
102
+ // A Readable body otherwise uses chunked transfer encoding, which
103
+ // Apple's fixed-length upload destinations may reject.
104
+ headers.set('content-length', String(op.length));
105
+ const part = await source(op.offset, op.length);
106
+ try {
107
+ const response = await fetch(op.url, { method: 'PUT', headers, body: part, duplex: 'half', redirect: 'error', signal: AbortSignal.timeout(120_000) });
108
+ if (!response.ok) throw new Error(`Apple asset upload returned HTTP ${response.status}. Retry with this same request key to refresh its upload URLs.`);
109
+ await response.body?.cancel();
110
+ } finally { if (typeof part.destroy === 'function') part.destroy(); }
111
+ }
112
+ }
113
+ if (handle) {
114
+ const latest = await handle.stat({ bigint: true });
115
+ if (['size', 'mtimeNs', 'ctimeNs', 'ino'].some(k => latest[k] !== current[k])) throw new Error('The source file changed during upload; it was not committed.');
116
+ }
117
+ stage = 'commit';
118
+ const committed = await write({ operation: commitOperation, scope: reservation.scope, body: { data: { type: reservation.resource.type, id: reservation.resource.id, attributes: { uploaded: true, ...(commitFields.sourceFileChecksum ? { sourceFileChecksum: md5 } : {}) } } }, confirm: true }, `${requestKey}:commit`);
119
+ if (committed.state === 'pending') return { ...committed, request_key: requestKey, commit_request_key: `${requestKey}:commit`, stage, resource: reservation.resource, scope: reservation.scope };
120
+ stage = 'processing';
121
+ state = await read(); attributes = state.data?.attributes;
122
+ const deliveryState = attributes?.assetDeliveryState?.state;
123
+ return { request_key: requestKey, resource: reservation.resource, scope: reservation.scope, file_size: size, sha256, uploaded: true, ready: deliveryState === 'COMPLETE', state: deliveryState === 'COMPLETE' ? 'complete' : deliveryState === 'FAILED' ? 'failed' : 'processing', asset_delivery_state: attributes?.assetDeliveryState, next_steps: 'Poll read_store with this scope until assetDeliveryState is COMPLETE. A committed upload may still be processing or rejected by Apple. This uploaded a new file; existing assets were not replaced.' };
124
+ } catch (error) {
125
+ return { state: reservation?.resource ? 'needs_attention' : 'failed', stage, request_key: requestKey, ...(reservation?.resource ? { resource: reservation.resource, scope: reservation.scope } : {}), error: { code: error.code ?? 'store_upload_failed', message: error.friendly ?? error.message }, next_steps: 'Keep the original file and request_key to resume. Completed reservation/commit requests are replayed; upload PUTs may repeat the identical byte ranges. Do not start another reservation for an unconfirmed request.' };
126
+ } finally { await handle?.close(); }
127
+ }