@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nomac/cli",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
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
@@ -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, 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.
@@ -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
- 3. `publish` (no confirm) staged dry-run; fix any Apple blockers it returns.
52
- 4. Ask the human explicitly before `publish confirm=true` — it's irreversible.
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
- // preferred: direct-to-storage upload (no ~4MB API body limit), then confirm.
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 api("POST", `/api/v1/projects/${state.project_id}/snapshots`, {
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 + latest version localizations) from App Store Connect.",
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
- "Replace one display type's screenshot set (all-or-nothing swap; exact dimensions validated first see get_metadata_schema). images: [{filename, data: base64 PNG}].",
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). 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.",
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 ig = ignoreFactory().add(ALWAYS_IGNORE);
28
- try {
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 = relative(dir, full);
40
+ const rel = relativePath(dir, full);
39
41
  const relForIgnore = entry.isDirectory() ? `${rel}/` : rel;
40
- if (ig.ignores(relForIgnore)) continue;
41
- if (entry.isDirectory()) walk(full);
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
+ }