@nomac/cli 0.2.2 → 0.2.4
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 +1 -1
- package/skill/SKILL.md +61 -6
- package/src/api.mjs +7 -3
- package/src/cli.mjs +9 -30
- package/src/lifecycle-schemas.json +215 -0
- package/src/mcp.mjs +86 -15
- package/src/pack.mjs +22 -12
- package/src/upload.mjs +36 -0
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -18,9 +18,9 @@ via MCP tools (preferred) or the `nomac` CLI.
|
|
|
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,
|
|
22
|
-
|
|
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.
|
|
@@ -47,9 +47,64 @@ status # poll ~30s until ready | failed
|
|
|
47
47
|
2. `set_metadata` — you write the copy; check `get_metadata_schema` for
|
|
48
48
|
limits. Age rating + data usage answers are legal attestations: ask the
|
|
49
49
|
human, relay their answers. `upload_screenshots` needs exact dimensions
|
|
50
|
-
(in the schema).
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
(in the schema). It runs in the background: poll `get_screenshot_upload`
|
|
51
|
+
until complete before publishing. A rolled-back or paused replacement
|
|
52
|
+
needs the returned recovery instructions before starting another upload.
|
|
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.
|
|
58
|
+
|
|
59
|
+
If metadata or publish returns pending, keep its `request_key` and repeat
|
|
60
|
+
the original arguments with that key. `get_store_operation` recovers the key
|
|
61
|
+
and progress after a lost response. A new key starts a separate operation;
|
|
62
|
+
do not use one to bypass an unconfirmed Apple request. Staging and confirmation
|
|
63
|
+
remain separate invocations. For screenshots, keep the original `request_key`
|
|
64
|
+
too, and use `get_screenshot_upload` to find or poll the existing operation.
|
|
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. Apple/account/legal decisions
|
|
85
|
+
and unsupported product edits still require the relevant Apple workflow.
|
|
86
|
+
|
|
87
|
+
## External TestFlight
|
|
88
|
+
|
|
89
|
+
`ready` from a nomac build means internal TestFlight readiness. External testing
|
|
90
|
+
requires its own review and distribution:
|
|
91
|
+
|
|
92
|
+
1. `get_testflight` lists groups, test information and recent Apple builds. Use
|
|
93
|
+
`asc_build_id` for a specific build's beta-review/external state, or `group_id`
|
|
94
|
+
to list testers. Never infer external approval from internal readiness.
|
|
95
|
+
2. `manage_testflight action=metadata` sets app_info (description/feedbackEmail/URLs),
|
|
96
|
+
review_contact (Apple contact/demo fields), locale, and build whats_new.
|
|
97
|
+
3. Select/create an external group. `invite_tester`/`remove_tester` use an explicit
|
|
98
|
+
email/tester ID and group_id. `update_group` can enable an optional public link.
|
|
99
|
+
4. `distribute` requires asc_build_id and explicit group_ids. It submits beta
|
|
100
|
+
review if needed, then assigns those groups. auto_notify defaults false;
|
|
101
|
+
`notify` starts testing after approval. `remove_build` affects one group;
|
|
102
|
+
`expire` stops testing for the build across all groups.
|
|
103
|
+
5. All actions preview without confirm and apply with confirm:true. Pending
|
|
104
|
+
requests use their original key/body; get_store_operation recovers them.
|
|
105
|
+
6. Relay an accurate human-provided uses_non_exempt_encryption with action=encryption
|
|
106
|
+
when appropriate. Never guess. Rejected beta messages and compliance documents
|
|
107
|
+
require App Store Connect; do not blindly resubmit an unchanged rejected binary.
|
|
53
108
|
|
|
54
109
|
## When stuck
|
|
55
110
|
|
package/src/api.mjs
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { loadApiKey, loadApiUrl } from "./config.mjs";
|
|
2
2
|
|
|
3
3
|
export class CliError extends Error {
|
|
4
|
-
constructor(message, friendly) {
|
|
4
|
+
constructor(message, friendly, status, code) {
|
|
5
5
|
super(message);
|
|
6
6
|
this.friendly = friendly ?? message;
|
|
7
7
|
this.exitCode = 1;
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.code = code;
|
|
8
10
|
}
|
|
9
11
|
}
|
|
10
12
|
|
|
@@ -21,7 +23,7 @@ export async function apiPublic(method, path, body) {
|
|
|
21
23
|
try {
|
|
22
24
|
json = text ? JSON.parse(text) : {};
|
|
23
25
|
} catch {
|
|
24
|
-
throw new CliError(`API returned non-JSON (HTTP ${res.status}): ${text.slice(0, 200)}
|
|
26
|
+
throw new CliError(`API returned non-JSON (HTTP ${res.status}): ${text.slice(0, 200)}`, undefined, res.status);
|
|
25
27
|
}
|
|
26
28
|
return { status: res.status, ok: res.ok, json };
|
|
27
29
|
}
|
|
@@ -53,13 +55,15 @@ export async function api(method, path, { body, raw, idempotencyKey } = {}) {
|
|
|
53
55
|
try {
|
|
54
56
|
json = text ? JSON.parse(text) : {};
|
|
55
57
|
} catch {
|
|
56
|
-
throw new CliError(`API returned non-JSON (HTTP ${res.status}): ${text.slice(0, 200)}
|
|
58
|
+
throw new CliError(`API returned non-JSON (HTTP ${res.status}): ${text.slice(0, 200)}`, undefined, res.status);
|
|
57
59
|
}
|
|
58
60
|
if (!res.ok) {
|
|
59
61
|
const err = json?.error ?? {};
|
|
60
62
|
throw new CliError(
|
|
61
63
|
`HTTP ${res.status} ${err.code ?? ""}`,
|
|
62
64
|
err.message ?? `API error (HTTP ${res.status})`,
|
|
65
|
+
res.status,
|
|
66
|
+
err.code,
|
|
63
67
|
);
|
|
64
68
|
}
|
|
65
69
|
return json;
|
package/src/cli.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { basename } from "node:path";
|
|
|
2
2
|
import { api, CliError } from "./api.mjs";
|
|
3
3
|
import { loadProjectState, saveApiKey, saveProjectState } from "./config.mjs";
|
|
4
4
|
import { packTarball } from "./pack.mjs";
|
|
5
|
+
import { uploadSourceSnapshot } from "./upload.mjs";
|
|
5
6
|
|
|
6
7
|
const HELP = `nomac — ship iOS apps without a Mac (agent-first)
|
|
7
8
|
|
|
@@ -85,36 +86,7 @@ async function push() {
|
|
|
85
86
|
const { tarball, files } = await packTarball(process.cwd());
|
|
86
87
|
console.log(`${files.length} files, ${(tarball.length / 1024).toFixed(0)} KB compressed`);
|
|
87
88
|
|
|
88
|
-
|
|
89
|
-
// Servers without the uploads route get the legacy inline body instead.
|
|
90
|
-
let grant = null;
|
|
91
|
-
try {
|
|
92
|
-
grant = await api("POST", `/api/v1/projects/${state.project_id}/snapshots/uploads`);
|
|
93
|
-
} catch {
|
|
94
|
-
grant = null;
|
|
95
|
-
}
|
|
96
|
-
let res;
|
|
97
|
-
if (grant) {
|
|
98
|
-
if (tarball.length > grant.max_bytes) {
|
|
99
|
-
throw new CliError(
|
|
100
|
-
"tarball too large",
|
|
101
|
-
`Packed source is ${(tarball.length / 1048576).toFixed(1)} MB; the limit is ${(grant.max_bytes / 1048576).toFixed(0)} MB. Check .gitignore: build artifacts are recreated on the build VM.`,
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
const up = await fetch(grant.upload_url, {
|
|
105
|
-
method: "PUT",
|
|
106
|
-
headers: { "content-type": "application/gzip" },
|
|
107
|
-
body: tarball,
|
|
108
|
-
});
|
|
109
|
-
if (!up.ok) throw new CliError(`upload failed (HTTP ${up.status})`);
|
|
110
|
-
res = await api("POST", `/api/v1/projects/${state.project_id}/snapshots`, {
|
|
111
|
-
body: { upload_key: grant.upload_key },
|
|
112
|
-
});
|
|
113
|
-
} else {
|
|
114
|
-
res = await api("POST", `/api/v1/projects/${state.project_id}/snapshots`, {
|
|
115
|
-
raw: tarball,
|
|
116
|
-
});
|
|
117
|
-
}
|
|
89
|
+
const res = await uploadSourceSnapshot(state.project_id, tarball);
|
|
118
90
|
console.log(`✅ snapshot ${res.snapshot_id} (commit ${res.commit.slice(0, 8)})`);
|
|
119
91
|
const d = res.detected ?? {};
|
|
120
92
|
const framework = d.project_kind
|
|
@@ -169,6 +141,7 @@ const SPINNER_STATES = {
|
|
|
169
141
|
async function waitForBuild(id) {
|
|
170
142
|
const started = Date.now();
|
|
171
143
|
let last = "";
|
|
144
|
+
let lastNotice = "";
|
|
172
145
|
for (;;) {
|
|
173
146
|
const b = await api("GET", `/api/v1/builds/${id}`);
|
|
174
147
|
if (b.state !== last) {
|
|
@@ -176,6 +149,12 @@ async function waitForBuild(id) {
|
|
|
176
149
|
console.log(` [${t}s] ${SPINNER_STATES[b.state] ?? b.state}`);
|
|
177
150
|
last = b.state;
|
|
178
151
|
}
|
|
152
|
+
const notice = b.error?.stage === "testflight" ? b.error.raw : "";
|
|
153
|
+
if (notice && notice !== lastNotice) {
|
|
154
|
+
console.log(` ${notice}`);
|
|
155
|
+
if (b.error.url) console.log(` ${b.error.url}`);
|
|
156
|
+
}
|
|
157
|
+
lastNotice = notice;
|
|
179
158
|
if (b.state === "ready") {
|
|
180
159
|
console.log(
|
|
181
160
|
b.workflow === "smoke"
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
{
|
|
2
|
+
"update_review": {
|
|
3
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"properties": {
|
|
6
|
+
"asc_submission_id": {
|
|
7
|
+
"type": "string",
|
|
8
|
+
"minLength": 1
|
|
9
|
+
},
|
|
10
|
+
"action": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"enum": [
|
|
13
|
+
"resolve_item",
|
|
14
|
+
"remove_item",
|
|
15
|
+
"cancel",
|
|
16
|
+
"resubmit",
|
|
17
|
+
"release"
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
"item_id": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"minLength": 1
|
|
23
|
+
},
|
|
24
|
+
"confirm": {
|
|
25
|
+
"default": false,
|
|
26
|
+
"type": "boolean"
|
|
27
|
+
},
|
|
28
|
+
"force": {
|
|
29
|
+
"default": false,
|
|
30
|
+
"type": "boolean"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"required": [
|
|
34
|
+
"asc_submission_id",
|
|
35
|
+
"action"
|
|
36
|
+
],
|
|
37
|
+
"additionalProperties": false
|
|
38
|
+
},
|
|
39
|
+
"prepare_review_reply": {
|
|
40
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
41
|
+
"type": "object",
|
|
42
|
+
"properties": {
|
|
43
|
+
"asc_submission_id": {
|
|
44
|
+
"type": "string",
|
|
45
|
+
"minLength": 1
|
|
46
|
+
},
|
|
47
|
+
"reply": {
|
|
48
|
+
"type": "string",
|
|
49
|
+
"minLength": 1,
|
|
50
|
+
"maxLength": 4000
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"required": [
|
|
54
|
+
"asc_submission_id",
|
|
55
|
+
"reply"
|
|
56
|
+
],
|
|
57
|
+
"additionalProperties": false
|
|
58
|
+
},
|
|
59
|
+
"manage_testflight": {
|
|
60
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
61
|
+
"type": "object",
|
|
62
|
+
"properties": {
|
|
63
|
+
"action": {
|
|
64
|
+
"type": "string",
|
|
65
|
+
"enum": [
|
|
66
|
+
"metadata",
|
|
67
|
+
"create_group",
|
|
68
|
+
"update_group",
|
|
69
|
+
"invite_tester",
|
|
70
|
+
"remove_tester",
|
|
71
|
+
"remove_build",
|
|
72
|
+
"distribute",
|
|
73
|
+
"notify",
|
|
74
|
+
"expire",
|
|
75
|
+
"encryption"
|
|
76
|
+
]
|
|
77
|
+
},
|
|
78
|
+
"asc_build_id": {
|
|
79
|
+
"type": "string",
|
|
80
|
+
"minLength": 1
|
|
81
|
+
},
|
|
82
|
+
"group_ids": {
|
|
83
|
+
"minItems": 1,
|
|
84
|
+
"maxItems": 100,
|
|
85
|
+
"type": "array",
|
|
86
|
+
"items": {
|
|
87
|
+
"type": "string",
|
|
88
|
+
"minLength": 1
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
"group_id": {
|
|
92
|
+
"type": "string",
|
|
93
|
+
"minLength": 1
|
|
94
|
+
},
|
|
95
|
+
"name": {
|
|
96
|
+
"type": "string",
|
|
97
|
+
"minLength": 1,
|
|
98
|
+
"maxLength": 100
|
|
99
|
+
},
|
|
100
|
+
"email": {
|
|
101
|
+
"type": "string",
|
|
102
|
+
"format": "email",
|
|
103
|
+
"pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
|
|
104
|
+
},
|
|
105
|
+
"first_name": {
|
|
106
|
+
"type": "string",
|
|
107
|
+
"maxLength": 100
|
|
108
|
+
},
|
|
109
|
+
"last_name": {
|
|
110
|
+
"type": "string",
|
|
111
|
+
"maxLength": 100
|
|
112
|
+
},
|
|
113
|
+
"tester_id": {
|
|
114
|
+
"type": "string",
|
|
115
|
+
"minLength": 1
|
|
116
|
+
},
|
|
117
|
+
"public_link_enabled": {
|
|
118
|
+
"type": "boolean"
|
|
119
|
+
},
|
|
120
|
+
"public_link_limit": {
|
|
121
|
+
"type": "integer",
|
|
122
|
+
"minimum": 1,
|
|
123
|
+
"maximum": 10000
|
|
124
|
+
},
|
|
125
|
+
"auto_notify": {
|
|
126
|
+
"default": false,
|
|
127
|
+
"type": "boolean"
|
|
128
|
+
},
|
|
129
|
+
"uses_non_exempt_encryption": {
|
|
130
|
+
"type": "boolean"
|
|
131
|
+
},
|
|
132
|
+
"locale": {
|
|
133
|
+
"default": "en-US",
|
|
134
|
+
"type": "string",
|
|
135
|
+
"minLength": 2,
|
|
136
|
+
"maxLength": 35
|
|
137
|
+
},
|
|
138
|
+
"app_info": {
|
|
139
|
+
"type": "object",
|
|
140
|
+
"properties": {
|
|
141
|
+
"description": {
|
|
142
|
+
"type": "string",
|
|
143
|
+
"maxLength": 4000
|
|
144
|
+
},
|
|
145
|
+
"feedbackEmail": {
|
|
146
|
+
"type": "string",
|
|
147
|
+
"format": "email",
|
|
148
|
+
"pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
|
|
149
|
+
},
|
|
150
|
+
"marketingUrl": {
|
|
151
|
+
"type": "string",
|
|
152
|
+
"maxLength": 255,
|
|
153
|
+
"format": "uri"
|
|
154
|
+
},
|
|
155
|
+
"privacyPolicyUrl": {
|
|
156
|
+
"type": "string",
|
|
157
|
+
"maxLength": 255,
|
|
158
|
+
"format": "uri"
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
"additionalProperties": false
|
|
162
|
+
},
|
|
163
|
+
"review_contact": {
|
|
164
|
+
"type": "object",
|
|
165
|
+
"properties": {
|
|
166
|
+
"contactFirstName": {
|
|
167
|
+
"type": "string",
|
|
168
|
+
"maxLength": 100
|
|
169
|
+
},
|
|
170
|
+
"contactLastName": {
|
|
171
|
+
"type": "string",
|
|
172
|
+
"maxLength": 100
|
|
173
|
+
},
|
|
174
|
+
"contactPhone": {
|
|
175
|
+
"type": "string",
|
|
176
|
+
"maxLength": 100
|
|
177
|
+
},
|
|
178
|
+
"contactEmail": {
|
|
179
|
+
"type": "string",
|
|
180
|
+
"format": "email",
|
|
181
|
+
"pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
|
|
182
|
+
},
|
|
183
|
+
"demoAccountRequired": {
|
|
184
|
+
"type": "boolean"
|
|
185
|
+
},
|
|
186
|
+
"demoAccountName": {
|
|
187
|
+
"type": "string",
|
|
188
|
+
"maxLength": 100
|
|
189
|
+
},
|
|
190
|
+
"demoAccountPassword": {
|
|
191
|
+
"type": "string",
|
|
192
|
+
"maxLength": 100
|
|
193
|
+
},
|
|
194
|
+
"notes": {
|
|
195
|
+
"type": "string",
|
|
196
|
+
"maxLength": 4000
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
"additionalProperties": false
|
|
200
|
+
},
|
|
201
|
+
"whats_new": {
|
|
202
|
+
"type": "string",
|
|
203
|
+
"maxLength": 4000
|
|
204
|
+
},
|
|
205
|
+
"confirm": {
|
|
206
|
+
"default": false,
|
|
207
|
+
"type": "boolean"
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
"required": [
|
|
211
|
+
"action"
|
|
212
|
+
],
|
|
213
|
+
"additionalProperties": false
|
|
214
|
+
}
|
|
215
|
+
}
|
package/src/mcp.mjs
CHANGED
|
@@ -3,10 +3,13 @@
|
|
|
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";
|
|
12
|
+
import { uploadSourceSnapshot } from "./upload.mjs";
|
|
10
13
|
|
|
11
14
|
const PROTOCOL_FALLBACK = "2025-06-18";
|
|
12
15
|
|
|
@@ -43,6 +46,15 @@ export const TOOLS = [
|
|
|
43
46
|
return text({ org: me.org, connection: detail });
|
|
44
47
|
},
|
|
45
48
|
},
|
|
49
|
+
{
|
|
50
|
+
name: "set_project_connection",
|
|
51
|
+
description: "Choose an Apple connection for a project, including after key rotation. Verifies the app's bundle ID. Active release builds must finish first.",
|
|
52
|
+
inputSchema: {
|
|
53
|
+
type: "object", properties: { project_id: { type: "string" }, connection_id: { type: "string" } },
|
|
54
|
+
required: ["connection_id"], additionalProperties: false,
|
|
55
|
+
},
|
|
56
|
+
handler: async args => text(await api("PATCH", `/api/v1/projects/${await resolveProjectId(args)}`, { body: { connection_id: args.connection_id } })),
|
|
57
|
+
},
|
|
46
58
|
{
|
|
47
59
|
name: "push_project",
|
|
48
60
|
description:
|
|
@@ -64,9 +76,7 @@ export const TOOLS = [
|
|
|
64
76
|
saveProjectState(state, dir);
|
|
65
77
|
}
|
|
66
78
|
const { tarball, files } = await packTarball(dir);
|
|
67
|
-
const res = await
|
|
68
|
-
raw: tarball,
|
|
69
|
-
});
|
|
79
|
+
const res = await uploadSourceSnapshot(state.project_id, tarball);
|
|
70
80
|
saveProjectState({ ...state, last_snapshot: res.snapshot_id }, dir);
|
|
71
81
|
return text({ project_id: state.project_id, files_packed: files.length, ...res });
|
|
72
82
|
},
|
|
@@ -89,8 +99,22 @@ export const TOOLS = [
|
|
|
89
99
|
body: { project_id, workflow: args?.workflow ?? "release" },
|
|
90
100
|
idempotencyKey: `mcp-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
91
101
|
});
|
|
102
|
+
let warning;
|
|
103
|
+
try {
|
|
104
|
+
const state = loadProjectState();
|
|
105
|
+
if (state?.project_id === project_id) {
|
|
106
|
+
saveProjectState({ ...state, last_build: res.id });
|
|
107
|
+
} else {
|
|
108
|
+
warning = "Build started for a project outside this directory's saved project. Pass build_id to status.";
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
// The paid attempt already exists. Return its ID even when the local
|
|
112
|
+
// directory is read-only, so the agent can poll without starting again.
|
|
113
|
+
warning = "Build started, but its ID could not be saved locally. Pass build_id to status.";
|
|
114
|
+
}
|
|
92
115
|
return text({
|
|
93
116
|
...res,
|
|
117
|
+
...(warning ? { warning } : {}),
|
|
94
118
|
note: "poll `status` every ~30s; a release build reaching `ready` means the phone can install it from TestFlight",
|
|
95
119
|
});
|
|
96
120
|
},
|
|
@@ -141,24 +165,26 @@ export const TOOLS = [
|
|
|
141
165
|
},
|
|
142
166
|
{
|
|
143
167
|
name: "get_metadata",
|
|
144
|
-
description: "Current App Store metadata (app +
|
|
168
|
+
description: "Current App Store metadata (app + selected iOS version localizations) from App Store Connect.",
|
|
145
169
|
inputSchema: {
|
|
146
170
|
type: "object",
|
|
147
|
-
properties: { project_id: { type: "string" } },
|
|
171
|
+
properties: { project_id: { type: "string" }, version_string: { type: "string", description: "Defaults to the pushed source marketing version" } },
|
|
148
172
|
additionalProperties: false,
|
|
149
173
|
},
|
|
150
174
|
handler: async (args) =>
|
|
151
|
-
text(await api("GET", `/api/v1/projects/${await resolveProjectId(args)}/metadata`)),
|
|
175
|
+
text(await api("GET", `/api/v1/projects/${await resolveProjectId(args)}/metadata${args?.version_string ? `?version_string=${encodeURIComponent(args.version_string)}` : ""}`)),
|
|
152
176
|
},
|
|
153
177
|
{
|
|
154
178
|
name: "set_metadata",
|
|
155
179
|
description:
|
|
156
|
-
"Write App Store metadata YOU authored (validated against limits before any Apple call). fields: description, keywords, whats_new, support_url, marketing_url, promotional_text, name, subtitle, privacy_policy_url. Also: primary_category (e.g. UTILITIES), age_rating (attestations from your human), content_rights, copyright, review_contact{...,demo_account}, price:'FREE'.",
|
|
180
|
+
"Write App Store metadata YOU authored (validated against limits before any Apple call). fields: description, keywords, whats_new, support_url, marketing_url, promotional_text, name, subtitle, privacy_policy_url. Also: primary_category (e.g. UTILITIES), age_rating (attestations from your human), content_rights, copyright, review_contact{...,demo_account}, price:'FREE'. On pending, repeat unchanged arguments with the returned request_key; get_store_operation recovers a lost response.",
|
|
157
181
|
inputSchema: {
|
|
158
182
|
type: "object",
|
|
159
183
|
properties: {
|
|
160
184
|
project_id: { type: "string" },
|
|
161
185
|
locale: { type: "string", default: "en-US" },
|
|
186
|
+
version_string: { type: "string", description: "Defaults to the pushed source marketing version; select a live version explicitly for promotional text" },
|
|
187
|
+
request_key: { type: "string", description: "Reuse the returned key to resume pending work with the original unchanged arguments" },
|
|
162
188
|
fields: { type: "object" },
|
|
163
189
|
primary_category: { type: "string" },
|
|
164
190
|
age_rating: { type: "object" },
|
|
@@ -170,21 +196,23 @@ export const TOOLS = [
|
|
|
170
196
|
additionalProperties: false,
|
|
171
197
|
},
|
|
172
198
|
handler: async (args) => {
|
|
173
|
-
const { project_id: _p, ...rest } = args ?? {};
|
|
199
|
+
const { project_id: _p, request_key, ...rest } = args ?? {};
|
|
174
200
|
const project_id = await resolveProjectId(args);
|
|
175
|
-
return text(await api("PUT", `/api/v1/projects/${project_id}/metadata`, { body: rest }));
|
|
201
|
+
return text(await api("PUT", `/api/v1/projects/${project_id}/metadata`, { body: rest, idempotencyKey: request_key }));
|
|
176
202
|
},
|
|
177
203
|
},
|
|
178
204
|
{
|
|
179
205
|
name: "upload_screenshots",
|
|
180
206
|
description:
|
|
181
|
-
"
|
|
207
|
+
"Start a screenshot replacement for one display type and iOS version. PNGs are validated and prior images are backed up. Reuse request_key and the original body when retrying. Poll get_screenshot_upload until complete, rolled_back or needs_attention; accepted means still in progress. images: [{filename, data: base64 PNG}].",
|
|
182
208
|
inputSchema: {
|
|
183
209
|
type: "object",
|
|
184
210
|
properties: {
|
|
185
211
|
project_id: { type: "string" },
|
|
186
212
|
display_type: { type: "string" },
|
|
213
|
+
request_key: { type: "string", description: "Unique per replacement; reuse this key and the same images on a retry" },
|
|
187
214
|
locale: { type: "string", default: "en-US" },
|
|
215
|
+
version_string: { type: "string", description: "Defaults to the pushed source marketing version" },
|
|
188
216
|
images: { type: "array", items: { type: "object" } },
|
|
189
217
|
},
|
|
190
218
|
required: ["display_type", "images"],
|
|
@@ -192,19 +220,41 @@ export const TOOLS = [
|
|
|
192
220
|
},
|
|
193
221
|
handler: async (args) => {
|
|
194
222
|
const project_id = await resolveProjectId(args);
|
|
195
|
-
const { project_id: _p, ...rest } = args;
|
|
196
|
-
return text(await api("POST", `/api/v1/projects/${project_id}/screenshots`, { body: rest }));
|
|
223
|
+
const { project_id: _p, request_key, ...rest } = args;
|
|
224
|
+
return text(await api("POST", `/api/v1/projects/${project_id}/screenshots`, { body: rest, idempotencyKey: request_key }));
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
name: "get_screenshot_upload",
|
|
229
|
+
description: "Check screenshot replacement/restoration. Omit operation_id to list recent operations after a lost upload response. complete confirms processed images; rolled_back means previous images were retained/restored; needs_attention requires support reconciliation.",
|
|
230
|
+
inputSchema: {
|
|
231
|
+
type: "object", properties: { project_id: { type: "string" }, operation_id: { type: "string" } },
|
|
232
|
+
additionalProperties: false,
|
|
233
|
+
},
|
|
234
|
+
handler: async args => {
|
|
235
|
+
const project_id = await resolveProjectId(args);
|
|
236
|
+
return text(await api("GET", `/api/v1/projects/${project_id}/screenshots${args.operation_id ? `?operation_id=${encodeURIComponent(args.operation_id)}` : ""}`));
|
|
197
237
|
},
|
|
198
238
|
},
|
|
239
|
+
{
|
|
240
|
+
name: "get_store_operation",
|
|
241
|
+
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.",
|
|
242
|
+
inputSchema: { type: "object", properties: { project_id: { type: "string" }, request_key: { type: "string" } }, additionalProperties: false },
|
|
243
|
+
handler: async args => text(await api("GET", `/api/v1/projects/${await resolveProjectId(args)}/operations${args?.request_key ? `?request_key=${encodeURIComponent(args.request_key)}` : ""}`)),
|
|
244
|
+
},
|
|
199
245
|
{
|
|
200
246
|
name: "publish",
|
|
201
247
|
description:
|
|
202
|
-
"Submit for App Store review (3-step reviewSubmissions).
|
|
248
|
+
"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.",
|
|
203
249
|
inputSchema: {
|
|
204
250
|
type: "object",
|
|
205
251
|
properties: {
|
|
206
252
|
project_id: { type: "string" },
|
|
253
|
+
build_id: { type: "string", description: "Exact nomac release build to attach" },
|
|
254
|
+
asc_submission_id: { type: "string", description: "Exact Apple submission from get_review" },
|
|
255
|
+
resolve_rejection: { type: "boolean", default: false, description: "Assert this version rejection was fixed; mark its item ready for review" },
|
|
207
256
|
confirm: { type: "boolean", default: false },
|
|
257
|
+
request_key: { type: "string", description: "Only supply to resume an interrupted invocation with its original unchanged arguments" },
|
|
208
258
|
force: { type: "boolean", default: false, description: "override a red lint gate (not recommended)" },
|
|
209
259
|
},
|
|
210
260
|
additionalProperties: false,
|
|
@@ -216,15 +266,36 @@ export const TOOLS = [
|
|
|
216
266
|
// this project forever. A later invocation must re-run mutable lint and
|
|
217
267
|
// App Store preflight checks; retries inside this invocation reuse this
|
|
218
268
|
// single key.
|
|
219
|
-
const idempotencyKey = `mcp-pub-${project_id}-${mode}-${randomUUID()}`;
|
|
269
|
+
const idempotencyKey = args?.request_key ?? `mcp-pub-${project_id}-${mode}-${randomUUID()}`;
|
|
220
270
|
return text(
|
|
221
271
|
await api("POST", "/api/v1/publish", {
|
|
222
|
-
body: { project_id, confirm: args?.confirm ?? false, force: args?.force ?? false },
|
|
272
|
+
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 },
|
|
223
273
|
idempotencyKey,
|
|
224
274
|
}),
|
|
225
275
|
);
|
|
226
276
|
},
|
|
227
277
|
},
|
|
278
|
+
...[
|
|
279
|
+
["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."],
|
|
280
|
+
["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."],
|
|
281
|
+
].map(([name, path, filter, description]) => ({
|
|
282
|
+
name, description,
|
|
283
|
+
inputSchema: { type: "object", properties: { project_id: { type: "string" }, [filter]: { type: "string" }, ...(name === "get_testflight" ? { group_id: { type: "string" } } : {}) }, additionalProperties: false },
|
|
284
|
+
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 } : {}) })}` : ""}`)),
|
|
285
|
+
})),
|
|
286
|
+
...[
|
|
287
|
+
["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."],
|
|
288
|
+
["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."],
|
|
289
|
+
["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."],
|
|
290
|
+
].map(([name, path, description]) => ({
|
|
291
|
+
name, description,
|
|
292
|
+
inputSchema: { ...lifecycleSchemas[name], properties: { project_id: { type: "string" }, ...lifecycleSchemas[name].properties, ...(name !== "prepare_review_reply" ? { request_key: { type: "string" } } : {}) } },
|
|
293
|
+
handler: async args => {
|
|
294
|
+
const projectId = await resolveProjectId(args);
|
|
295
|
+
const { project_id: _project, request_key, ...body } = args;
|
|
296
|
+
return text(await api("POST", `/api/v1/projects/${encodeURIComponent(projectId)}/${path}`, { body, idempotencyKey: request_key ?? randomUUID() }));
|
|
297
|
+
},
|
|
298
|
+
})),
|
|
228
299
|
{
|
|
229
300
|
name: "get_feedback",
|
|
230
301
|
description:
|
package/src/pack.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
|
-
import { join, relative } from "node:path";
|
|
2
|
+
import { join, relative, sep } from "node:path";
|
|
3
3
|
import ignoreFactory from "ignore";
|
|
4
4
|
import * as tar from "tar";
|
|
5
5
|
|
|
@@ -24,21 +24,31 @@ const ALWAYS_IGNORE = [
|
|
|
24
24
|
];
|
|
25
25
|
|
|
26
26
|
export function collectFiles(dir) {
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
ig.add(readFileSync(join(dir, ".gitignore"), "utf8"));
|
|
30
|
-
} catch {
|
|
31
|
-
// no .gitignore — fine
|
|
32
|
-
}
|
|
33
|
-
|
|
27
|
+
const denied = ignoreFactory().add(ALWAYS_IGNORE);
|
|
28
|
+
const relativePath = (base, path) => relative(base, path).split(sep).join("/");
|
|
34
29
|
const files = [];
|
|
35
|
-
const walk = (current) => {
|
|
30
|
+
const walk = (current, inherited = []) => {
|
|
31
|
+
let scopes = inherited;
|
|
32
|
+
try {
|
|
33
|
+
const rules = readFileSync(join(current, ".gitignore"), "utf8");
|
|
34
|
+
scopes = [...inherited, { base: current, ignore: ignoreFactory().add(rules) }];
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (error.code !== "ENOENT") throw error;
|
|
37
|
+
}
|
|
36
38
|
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
37
39
|
const full = join(current, entry.name);
|
|
38
|
-
const rel =
|
|
40
|
+
const rel = relativePath(dir, full);
|
|
39
41
|
const relForIgnore = entry.isDirectory() ? `${rel}/` : rel;
|
|
40
|
-
if (
|
|
41
|
-
|
|
42
|
+
if (denied.ignores(relForIgnore)) continue;
|
|
43
|
+
let ignored = false;
|
|
44
|
+
for (const scope of scopes) {
|
|
45
|
+
const scoped = relativePath(scope.base, full) + (entry.isDirectory() ? "/" : "");
|
|
46
|
+
const result = scope.ignore.test(scoped);
|
|
47
|
+
if (result.ignored) ignored = true;
|
|
48
|
+
else if (result.unignored) ignored = false;
|
|
49
|
+
}
|
|
50
|
+
if (ignored) continue;
|
|
51
|
+
if (entry.isDirectory()) walk(full, scopes);
|
|
42
52
|
else if (entry.isFile()) files.push(rel);
|
|
43
53
|
}
|
|
44
54
|
};
|
package/src/upload.mjs
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { api, CliError } from "./api.mjs";
|
|
2
|
+
|
|
3
|
+
/** Shared by the CLI and stdio MCP so both bypass the API's 4 MiB body limit. */
|
|
4
|
+
export async function uploadSourceSnapshot(projectId, tarball) {
|
|
5
|
+
const path = `/api/v1/projects/${projectId}/snapshots`;
|
|
6
|
+
let grant;
|
|
7
|
+
try {
|
|
8
|
+
grant = await api("POST", `${path}/uploads`);
|
|
9
|
+
} catch (error) {
|
|
10
|
+
// Older servers have no grant route. Outages and authorization failures
|
|
11
|
+
// should remain actionable, not silently retry through another transport.
|
|
12
|
+
if (!(error instanceof CliError) || error.status !== 404 || error.code) throw error;
|
|
13
|
+
if (tarball.length > 4 * 1024 * 1024) {
|
|
14
|
+
throw new CliError("direct uploads unavailable", "This server does not support direct source uploads. Update the server or reduce the compressed archive to 4 MiB.");
|
|
15
|
+
}
|
|
16
|
+
return api("POST", path, { raw: tarball });
|
|
17
|
+
}
|
|
18
|
+
if (typeof grant.upload_url !== "string" || typeof grant.upload_key !== "string" ||
|
|
19
|
+
!Number.isSafeInteger(grant.max_bytes) || grant.max_bytes <= 0) {
|
|
20
|
+
throw new CliError("invalid source upload grant");
|
|
21
|
+
}
|
|
22
|
+
if (tarball.length > grant.max_bytes) {
|
|
23
|
+
throw new CliError(
|
|
24
|
+
"tarball too large",
|
|
25
|
+
`Packed source is ${(tarball.length / 1048576).toFixed(1)} MiB; the limit is ${(grant.max_bytes / 1048576).toFixed(0)} MiB. Check .gitignore: build artifacts are recreated on the build VM.`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
const uploaded = await fetch(grant.upload_url, {
|
|
29
|
+
method: "PUT",
|
|
30
|
+
headers: { "content-type": "application/gzip", "content-length": String(tarball.length) },
|
|
31
|
+
body: tarball,
|
|
32
|
+
});
|
|
33
|
+
await uploaded.body?.cancel();
|
|
34
|
+
if (!uploaded.ok) throw new CliError(`upload failed (HTTP ${uploaded.status})`);
|
|
35
|
+
return api("POST", path, { body: { upload_key: grant.upload_key } });
|
|
36
|
+
}
|