@nomac/cli 0.2.2 → 0.2.3

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.3",
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
@@ -47,10 +47,19 @@ 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).
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.
51
53
  3. `publish` (no confirm) → staged dry-run; fix any Apple blockers it returns.
52
54
  4. Ask the human explicitly before `publish confirm=true` — it's irreversible.
53
55
 
56
+ If metadata or publish returns pending, keep its `request_key` and repeat
57
+ the original arguments with that key. `get_store_operation` recovers the key
58
+ and progress after a lost response. A new key starts a separate operation;
59
+ do not use one to bypass an unconfirmed Apple request. Staging and confirmation
60
+ remain separate invocations. For screenshots, keep the original `request_key`
61
+ too, and use `get_screenshot_upload` to find or poll the existing operation.
62
+
54
63
  ## When stuck
55
64
 
56
65
  `report_issue` with the failing `ref_id` files a support ticket with full
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"
package/src/mcp.mjs CHANGED
@@ -7,6 +7,7 @@ import { createInterface } from "node:readline";
7
7
  import { api } from "./api.mjs";
8
8
  import { loadProjectState, saveProjectState } from "./config.mjs";
9
9
  import { packTarball } from "./pack.mjs";
10
+ import { uploadSourceSnapshot } from "./upload.mjs";
10
11
 
11
12
  const PROTOCOL_FALLBACK = "2025-06-18";
12
13
 
@@ -43,6 +44,15 @@ export const TOOLS = [
43
44
  return text({ org: me.org, connection: detail });
44
45
  },
45
46
  },
47
+ {
48
+ name: "set_project_connection",
49
+ description: "Choose an Apple connection for a project, including after key rotation. Verifies the app's bundle ID. Active release builds must finish first.",
50
+ inputSchema: {
51
+ type: "object", properties: { project_id: { type: "string" }, connection_id: { type: "string" } },
52
+ required: ["connection_id"], additionalProperties: false,
53
+ },
54
+ handler: async args => text(await api("PATCH", `/api/v1/projects/${await resolveProjectId(args)}`, { body: { connection_id: args.connection_id } })),
55
+ },
46
56
  {
47
57
  name: "push_project",
48
58
  description:
@@ -64,9 +74,7 @@ export const TOOLS = [
64
74
  saveProjectState(state, dir);
65
75
  }
66
76
  const { tarball, files } = await packTarball(dir);
67
- const res = await api("POST", `/api/v1/projects/${state.project_id}/snapshots`, {
68
- raw: tarball,
69
- });
77
+ const res = await uploadSourceSnapshot(state.project_id, tarball);
70
78
  saveProjectState({ ...state, last_snapshot: res.snapshot_id }, dir);
71
79
  return text({ project_id: state.project_id, files_packed: files.length, ...res });
72
80
  },
@@ -89,8 +97,22 @@ export const TOOLS = [
89
97
  body: { project_id, workflow: args?.workflow ?? "release" },
90
98
  idempotencyKey: `mcp-${Date.now()}-${Math.random().toString(36).slice(2)}`,
91
99
  });
100
+ let warning;
101
+ try {
102
+ const state = loadProjectState();
103
+ if (state?.project_id === project_id) {
104
+ saveProjectState({ ...state, last_build: res.id });
105
+ } else {
106
+ warning = "Build started for a project outside this directory's saved project. Pass build_id to status.";
107
+ }
108
+ } catch {
109
+ // The paid attempt already exists. Return its ID even when the local
110
+ // directory is read-only, so the agent can poll without starting again.
111
+ warning = "Build started, but its ID could not be saved locally. Pass build_id to status.";
112
+ }
92
113
  return text({
93
114
  ...res,
115
+ ...(warning ? { warning } : {}),
94
116
  note: "poll `status` every ~30s; a release build reaching `ready` means the phone can install it from TestFlight",
95
117
  });
96
118
  },
@@ -141,24 +163,26 @@ export const TOOLS = [
141
163
  },
142
164
  {
143
165
  name: "get_metadata",
144
- description: "Current App Store metadata (app + latest version localizations) from App Store Connect.",
166
+ description: "Current App Store metadata (app + selected iOS version localizations) from App Store Connect.",
145
167
  inputSchema: {
146
168
  type: "object",
147
- properties: { project_id: { type: "string" } },
169
+ properties: { project_id: { type: "string" }, version_string: { type: "string", description: "Defaults to the pushed source marketing version" } },
148
170
  additionalProperties: false,
149
171
  },
150
172
  handler: async (args) =>
151
- text(await api("GET", `/api/v1/projects/${await resolveProjectId(args)}/metadata`)),
173
+ text(await api("GET", `/api/v1/projects/${await resolveProjectId(args)}/metadata${args?.version_string ? `?version_string=${encodeURIComponent(args.version_string)}` : ""}`)),
152
174
  },
153
175
  {
154
176
  name: "set_metadata",
155
177
  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'.",
178
+ "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
179
  inputSchema: {
158
180
  type: "object",
159
181
  properties: {
160
182
  project_id: { type: "string" },
161
183
  locale: { type: "string", default: "en-US" },
184
+ version_string: { type: "string", description: "Defaults to the pushed source marketing version; select a live version explicitly for promotional text" },
185
+ request_key: { type: "string", description: "Reuse the returned key to resume pending work with the original unchanged arguments" },
162
186
  fields: { type: "object" },
163
187
  primary_category: { type: "string" },
164
188
  age_rating: { type: "object" },
@@ -170,21 +194,23 @@ export const TOOLS = [
170
194
  additionalProperties: false,
171
195
  },
172
196
  handler: async (args) => {
173
- const { project_id: _p, ...rest } = args ?? {};
197
+ const { project_id: _p, request_key, ...rest } = args ?? {};
174
198
  const project_id = await resolveProjectId(args);
175
- return text(await api("PUT", `/api/v1/projects/${project_id}/metadata`, { body: rest }));
199
+ return text(await api("PUT", `/api/v1/projects/${project_id}/metadata`, { body: rest, idempotencyKey: request_key }));
176
200
  },
177
201
  },
178
202
  {
179
203
  name: "upload_screenshots",
180
204
  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}].",
205
+ "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
206
  inputSchema: {
183
207
  type: "object",
184
208
  properties: {
185
209
  project_id: { type: "string" },
186
210
  display_type: { type: "string" },
211
+ request_key: { type: "string", description: "Unique per replacement; reuse this key and the same images on a retry" },
187
212
  locale: { type: "string", default: "en-US" },
213
+ version_string: { type: "string", description: "Defaults to the pushed source marketing version" },
188
214
  images: { type: "array", items: { type: "object" } },
189
215
  },
190
216
  required: ["display_type", "images"],
@@ -192,19 +218,38 @@ export const TOOLS = [
192
218
  },
193
219
  handler: async (args) => {
194
220
  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 }));
221
+ const { project_id: _p, request_key, ...rest } = args;
222
+ return text(await api("POST", `/api/v1/projects/${project_id}/screenshots`, { body: rest, idempotencyKey: request_key }));
197
223
  },
198
224
  },
225
+ {
226
+ name: "get_screenshot_upload",
227
+ 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.",
228
+ inputSchema: {
229
+ type: "object", properties: { project_id: { type: "string" }, operation_id: { type: "string" } },
230
+ additionalProperties: false,
231
+ },
232
+ handler: async args => {
233
+ const project_id = await resolveProjectId(args);
234
+ return text(await api("GET", `/api/v1/projects/${project_id}/screenshots${args.operation_id ? `?operation_id=${encodeURIComponent(args.operation_id)}` : ""}`));
235
+ },
236
+ },
237
+ {
238
+ 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.",
240
+ inputSchema: { type: "object", properties: { project_id: { type: "string" }, request_key: { type: "string" } }, additionalProperties: false },
241
+ handler: async args => text(await api("GET", `/api/v1/projects/${await resolveProjectId(args)}/operations${args?.request_key ? `?request_key=${encodeURIComponent(args.request_key)}` : ""}`)),
242
+ },
199
243
  {
200
244
  name: "publish",
201
245
  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.",
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.",
203
247
  inputSchema: {
204
248
  type: "object",
205
249
  properties: {
206
250
  project_id: { type: "string" },
207
251
  confirm: { type: "boolean", default: false },
252
+ request_key: { type: "string", description: "Only supply to resume an interrupted invocation with its original unchanged arguments" },
208
253
  force: { type: "boolean", default: false, description: "override a red lint gate (not recommended)" },
209
254
  },
210
255
  additionalProperties: false,
@@ -216,7 +261,7 @@ export const TOOLS = [
216
261
  // this project forever. A later invocation must re-run mutable lint and
217
262
  // App Store preflight checks; retries inside this invocation reuse this
218
263
  // single key.
219
- const idempotencyKey = `mcp-pub-${project_id}-${mode}-${randomUUID()}`;
264
+ const idempotencyKey = args?.request_key ?? `mcp-pub-${project_id}-${mode}-${randomUUID()}`;
220
265
  return text(
221
266
  await api("POST", "/api/v1/publish", {
222
267
  body: { project_id, confirm: args?.confirm ?? false, force: args?.force ?? false },
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
+ }