@nomac/cli 0.1.0

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/bin/nomac.mjs ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "../src/cli.mjs";
3
+
4
+ main(process.argv.slice(2)).catch((err) => {
5
+ console.error(err?.friendly ?? err?.message ?? String(err));
6
+ process.exit(err?.exitCode ?? 1);
7
+ });
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@nomac/cli",
3
+ "version": "0.1.0",
4
+ "description": "Ship iOS apps to TestFlight and the App Store without a Mac — built for agents.",
5
+ "license": "UNLICENSED",
6
+ "type": "module",
7
+ "bin": {
8
+ "nomac": "./bin/nomac.mjs"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "files": [
14
+ "bin",
15
+ "src",
16
+ "skill"
17
+ ],
18
+ "scripts": {
19
+ "test": "vitest run"
20
+ },
21
+ "dependencies": {
22
+ "ignore": "^7.0.0",
23
+ "tar": "^7.4.0"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^26.1.1",
27
+ "typescript": "^5.8.0",
28
+ "vitest": "^3.0.0"
29
+ },
30
+ "engines": {
31
+ "node": ">=20"
32
+ }
33
+ }
package/skill/SKILL.md ADDED
@@ -0,0 +1,58 @@
1
+ ---
2
+ name: nomac
3
+ description: >
4
+ Ship iOS apps to TestFlight and the App Store without a Mac. Use when the
5
+ user asks to build, test on a phone, publish, or submit an iOS app for
6
+ review — or mentions TestFlight, App Store Connect, signing, provisioning,
7
+ screenshots/metadata for the App Store, or app review rejections. Works from
8
+ any directory containing an Xcode project (SwiftUI vibe-code exports
9
+ included).
10
+ ---
11
+
12
+ # nomac — ship iOS without a Mac
13
+
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 —
16
+ via MCP tools (preferred) or the `nomac` CLI.
17
+
18
+ ## Setup check
19
+
20
+ 1. MCP tools available (`connect_status`, `push_project`, `build`, …)? Use them.
21
+ 2. Otherwise: `npx nomac whoami`. If not logged in, ask the human for an API
22
+ key from nomac.app → API keys, then `npx nomac login <nmk_…>` and
23
+ `claude mcp add nomac -- npx nomac mcp`.
24
+ 3. `connect_status` must show a healthy Apple connection. If not, the human
25
+ connects at the dashboard (60 seconds, one .p8 upload). Don't guess —
26
+ verify, then proceed.
27
+
28
+ ## Ship to TestFlight
29
+
30
+ ```
31
+ push_project # after every code change
32
+ build workflow=release # ~3 min; smoke = fast compile-only check
33
+ status # poll ~30s until ready | failed
34
+ ```
35
+
36
+ - `ready` → tell the human: "open TestFlight on your phone." Their taps are
37
+ the test run; read `get_feedback` for crashes/comments afterwards.
38
+ - `failed` → `get_failure`: kind=project means you fix source and re-push;
39
+ kind=account means relay the link to the human. Don't burn release builds
40
+ debugging compile errors — use `build workflow=smoke`.
41
+
42
+ ## Submit for App Store review
43
+
44
+ 1. `review_lint` → fix every red. Apply returned patches (e.g. generated
45
+ `PrivacyInfo.xcprivacy`) directly to the source. Yellow "signals"
46
+ (4.3 distinctiveness, thin listing) are yours to judge and improve.
47
+ 2. `set_metadata` — you write the copy; check `get_metadata_schema` for
48
+ limits. Age rating + data usage answers are legal attestations: ask the
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.
53
+
54
+ ## When stuck
55
+
56
+ `report_issue` with the failing `ref_id` files a support ticket with full
57
+ context auto-attached (deduped, so safe to call). Read replies with the same
58
+ tool. Never retry-loop an identical failure more than twice.
package/src/api.mjs ADDED
@@ -0,0 +1,66 @@
1
+ import { loadApiKey, loadApiUrl } from "./config.mjs";
2
+
3
+ export class CliError extends Error {
4
+ constructor(message, friendly) {
5
+ super(message);
6
+ this.friendly = friendly ?? message;
7
+ this.exitCode = 1;
8
+ }
9
+ }
10
+
11
+ /** Unauthenticated request — for the device-login endpoints, which take no key. */
12
+ export async function apiPublic(method, path, body) {
13
+ const url = `${loadApiUrl()}${path}`;
14
+ const res = await fetch(url, {
15
+ method,
16
+ headers: body !== undefined ? { "content-type": "application/json" } : {},
17
+ body: body !== undefined ? JSON.stringify(body) : undefined,
18
+ });
19
+ const text = await res.text();
20
+ let json;
21
+ try {
22
+ json = text ? JSON.parse(text) : {};
23
+ } catch {
24
+ throw new CliError(`API returned non-JSON (HTTP ${res.status}): ${text.slice(0, 200)}`);
25
+ }
26
+ return { status: res.status, ok: res.ok, json };
27
+ }
28
+
29
+ export async function api(method, path, { body, raw, idempotencyKey } = {}) {
30
+ const key = loadApiKey();
31
+ if (!key) {
32
+ throw new CliError(
33
+ "not logged in",
34
+ "Not logged in. Run `nomac login <api-key>` (create a key in the dashboard) or set NOMAC_API_KEY.",
35
+ );
36
+ }
37
+ const url = `${loadApiUrl()}${path}`;
38
+ const res = await fetch(url, {
39
+ method,
40
+ headers: {
41
+ authorization: `Bearer ${key}`,
42
+ ...(raw
43
+ ? { "content-type": "application/gzip" }
44
+ : body !== undefined
45
+ ? { "content-type": "application/json" }
46
+ : {}),
47
+ ...(idempotencyKey ? { "idempotency-key": idempotencyKey } : {}),
48
+ },
49
+ body: raw ?? (body !== undefined ? JSON.stringify(body) : undefined),
50
+ });
51
+ const text = await res.text();
52
+ let json;
53
+ try {
54
+ json = text ? JSON.parse(text) : {};
55
+ } catch {
56
+ throw new CliError(`API returned non-JSON (HTTP ${res.status}): ${text.slice(0, 200)}`);
57
+ }
58
+ if (!res.ok) {
59
+ const err = json?.error ?? {};
60
+ throw new CliError(
61
+ `HTTP ${res.status} ${err.code ?? ""}`,
62
+ err.message ?? `API error (HTTP ${res.status})`,
63
+ );
64
+ }
65
+ return json;
66
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,172 @@
1
+ import { basename } from "node:path";
2
+ import { api, CliError } from "./api.mjs";
3
+ import { loadProjectState, saveApiKey, saveProjectState } from "./config.mjs";
4
+ import { packTarball } from "./pack.mjs";
5
+
6
+ const HELP = `nomac — ship iOS apps without a Mac (agent-first)
7
+
8
+ Usage:
9
+ nomac login connect this device (approve in the browser)
10
+ nomac login <api-key> store an nmk_ key directly (CI / power users)
11
+ nomac push pack this directory and upload a snapshot
12
+ nomac build [--smoke] start a release (or smoke) build
13
+ nomac build --testflight release build; waits until TestFlight-ready
14
+ nomac status [build-id] show latest (or given) build status
15
+ nomac logs <build-id> show build logs
16
+ nomac mcp run the stdio MCP server (for agents)
17
+ nomac whoami show org + usage
18
+
19
+ Environment: NOMAC_API_KEY, NOMAC_API_URL`;
20
+
21
+ export async function main(argv) {
22
+ const [cmd, ...rest] = argv;
23
+ switch (cmd) {
24
+ case "login": {
25
+ const key = rest[0];
26
+ if (key) {
27
+ // direct-key path for CI / power users
28
+ if (!key.startsWith("nmk_")) {
29
+ throw new CliError("bad key", "Usage: nomac login nmk_… (or just `nomac login` to approve in the browser)");
30
+ }
31
+ saveApiKey(key);
32
+ const me = await api("GET", "/api/v1/me");
33
+ console.log(`✅ logged in — org "${me.org.name}" (${me.org.id})`);
34
+ return;
35
+ }
36
+ const { deviceLogin } = await import("./login.mjs");
37
+ return deviceLogin();
38
+ }
39
+ case "push":
40
+ return push();
41
+ case "build":
42
+ return build(rest);
43
+ case "status":
44
+ return status(rest[0]);
45
+ case "logs":
46
+ return logs(rest[0]);
47
+ case "mcp": {
48
+ const { runMcpServer } = await import("./mcp.mjs");
49
+ return runMcpServer();
50
+ }
51
+ case "whoami": {
52
+ const me = await api("GET", "/api/v1/me");
53
+ console.log(JSON.stringify(me, null, 2));
54
+ return;
55
+ }
56
+ case undefined:
57
+ case "help":
58
+ case "--help":
59
+ console.log(HELP);
60
+ return;
61
+ default:
62
+ throw new CliError(`unknown command: ${cmd}`, `Unknown command "${cmd}".\n\n${HELP}`);
63
+ }
64
+ }
65
+
66
+ async function ensureProject() {
67
+ const state = loadProjectState();
68
+ if (state.project_id) return state;
69
+ const name = basename(process.cwd());
70
+ const created = await api("POST", "/api/v1/projects", { body: { name } });
71
+ const next = { project_id: created.id, name };
72
+ saveProjectState(next);
73
+ console.log(`created project ${created.id} ("${name}")`);
74
+ return next;
75
+ }
76
+
77
+ async function push() {
78
+ const state = await ensureProject();
79
+ console.log("packing working tree (respecting .gitignore, secrets excluded)…");
80
+ const { tarball, files } = await packTarball(process.cwd());
81
+ console.log(`${files.length} files, ${(tarball.length / 1024).toFixed(0)} KB compressed`);
82
+ const res = await api("POST", `/api/v1/projects/${state.project_id}/snapshots`, {
83
+ raw: tarball,
84
+ });
85
+ console.log(`✅ snapshot ${res.snapshot_id} (commit ${res.commit.slice(0, 8)})`);
86
+ const d = res.detected ?? {};
87
+ console.log(
88
+ ` detected: ${d.xcodeproj ?? "?"} · scheme ${d.scheme ?? "?"} · ${d.bundle_id ?? "no bundle id"}${d.marketing_version ? ` · v${d.marketing_version}` : ""}`,
89
+ );
90
+ if (res.warning) console.log(`⚠ ${res.warning}`);
91
+ saveProjectState({ ...state, last_snapshot: res.snapshot_id });
92
+ }
93
+
94
+ async function build(rest) {
95
+ const state = loadProjectState();
96
+ if (!state.project_id) {
97
+ throw new CliError("no project", "No project here — run `nomac push` first.");
98
+ }
99
+ const smoke = rest.includes("--smoke");
100
+ const wait = rest.includes("--testflight") || rest.includes("--wait");
101
+ const res = await api("POST", "/api/v1/builds", {
102
+ body: { project_id: state.project_id, workflow: smoke ? "smoke" : "release" },
103
+ idempotencyKey: `cli-${Date.now()}-${Math.random().toString(36).slice(2)}`,
104
+ });
105
+ console.log(`✅ build ${res.id} started (${res.workflow}, #${res.build_number})`);
106
+ saveProjectState({ ...state, last_build: res.id });
107
+ if (!wait) {
108
+ console.log(` follow with: nomac status ${res.id}`);
109
+ return;
110
+ }
111
+ await waitForBuild(res.id);
112
+ }
113
+
114
+ const SPINNER_STATES = {
115
+ queued: "queued",
116
+ mirrored: "mirrored",
117
+ dispatched: "runner starting",
118
+ building: "building on macOS",
119
+ uploading: "uploading to Apple",
120
+ processing: "Apple is processing",
121
+ };
122
+
123
+ async function waitForBuild(id) {
124
+ const started = Date.now();
125
+ let last = "";
126
+ for (;;) {
127
+ const b = await api("GET", `/api/v1/builds/${id}`);
128
+ if (b.state !== last) {
129
+ const t = Math.round((Date.now() - started) / 1000);
130
+ console.log(` [${t}s] ${SPINNER_STATES[b.state] ?? b.state}`);
131
+ last = b.state;
132
+ }
133
+ if (b.state === "ready") {
134
+ console.log(
135
+ b.workflow === "smoke"
136
+ ? "\n🎉 smoke build passed — the project compiles clean on macOS."
137
+ : "\n🎉 build ready — open TestFlight on your phone, the new build is live.",
138
+ );
139
+ return;
140
+ }
141
+ if (b.state === "failed") {
142
+ const err = b.error ?? {};
143
+ console.log(`\n❌ build failed (${err.code ?? "unknown"})`);
144
+ if (err.raw) console.log(` ${err.raw}`);
145
+ console.log(` details: nomac logs ${id}`);
146
+ const e = new CliError("build failed", "");
147
+ e.friendly = "";
148
+ throw e;
149
+ }
150
+ await new Promise((r) => setTimeout(r, 10_000));
151
+ }
152
+ }
153
+
154
+ async function status(buildId) {
155
+ const state = loadProjectState();
156
+ const id = buildId ?? state.last_build;
157
+ if (!id) throw new CliError("no build", "No build id. Usage: nomac status <build-id>");
158
+ const b = await api("GET", `/api/v1/builds/${id}`);
159
+ console.log(JSON.stringify(b, null, 2));
160
+ }
161
+
162
+ async function logs(buildId) {
163
+ const state = loadProjectState();
164
+ const id = buildId ?? state.last_build;
165
+ if (!id) throw new CliError("no build", "No build id. Usage: nomac logs <build-id>");
166
+ const res = await api("GET", `/api/v1/builds/${id}/logs`);
167
+ for (const step of res.steps ?? []) {
168
+ console.log(`\n═══ ${step.name} [${step.status ?? "?"}] ═══`);
169
+ if (step.log) console.log(step.log);
170
+ }
171
+ if (res.excerpt) console.log(`\n═══ failure excerpt ═══\n${res.excerpt}`);
172
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,54 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ export const DEFAULT_API_URL =
6
+ process.env.NOMAC_API_URL ?? "https://www.nomac.app";
7
+
8
+ const CONFIG_DIR = join(homedir(), ".nomac");
9
+ const CREDENTIALS = join(CONFIG_DIR, "credentials.json");
10
+
11
+ export function loadApiKey() {
12
+ if (process.env.NOMAC_API_KEY) return process.env.NOMAC_API_KEY;
13
+ try {
14
+ const creds = JSON.parse(readFileSync(CREDENTIALS, "utf8"));
15
+ return creds.api_key;
16
+ } catch {
17
+ return undefined;
18
+ }
19
+ }
20
+
21
+ export function saveApiKey(apiKey, apiUrl) {
22
+ mkdirSync(CONFIG_DIR, { recursive: true });
23
+ writeFileSync(
24
+ CREDENTIALS,
25
+ JSON.stringify({ api_key: apiKey, ...(apiUrl ? { api_url: apiUrl } : {}) }, null, 2),
26
+ { mode: 0o600 },
27
+ );
28
+ }
29
+
30
+ export function loadApiUrl() {
31
+ if (process.env.NOMAC_API_URL) return process.env.NOMAC_API_URL;
32
+ try {
33
+ const creds = JSON.parse(readFileSync(CREDENTIALS, "utf8"));
34
+ if (creds.api_url) return creds.api_url;
35
+ } catch {
36
+ // fall through
37
+ }
38
+ return DEFAULT_API_URL;
39
+ }
40
+
41
+ /** Per-project state (project id), stored next to the code. */
42
+ export function loadProjectState(dir = process.cwd()) {
43
+ const p = join(dir, ".nomac.json");
44
+ if (!existsSync(p)) return {};
45
+ try {
46
+ return JSON.parse(readFileSync(p, "utf8"));
47
+ } catch {
48
+ return {};
49
+ }
50
+ }
51
+
52
+ export function saveProjectState(state, dir = process.cwd()) {
53
+ writeFileSync(join(dir, ".nomac.json"), JSON.stringify(state, null, 2) + "\n");
54
+ }
package/src/login.mjs ADDED
@@ -0,0 +1,48 @@
1
+ import { hostname } from "node:os";
2
+ import { api, apiPublic, CliError } from "./api.mjs";
3
+ import { saveApiKey } from "./config.mjs";
4
+
5
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
6
+
7
+ /**
8
+ * Device-authorization login: no key is ever pasted into the agent. The CLI
9
+ * gets a short code, the human approves it in the browser, and the CLI polls
10
+ * until it can claim a freshly-minted API key.
11
+ */
12
+ export async function deviceLogin() {
13
+ const start = await apiPublic("POST", "/api/v1/device/start", { client_name: hostname() });
14
+ if (!start.ok) {
15
+ throw new CliError("device start failed", start.json?.error?.message ?? "Couldn't start login.");
16
+ }
17
+ const { device_code, user_code, verification_uri, verification_uri_complete, interval, expires_in } =
18
+ start.json;
19
+
20
+ console.log(`\n To connect this device, open:\n`);
21
+ console.log(` ${verification_uri_complete || verification_uri}\n`);
22
+ console.log(` and confirm this code: ${user_code}\n`);
23
+ console.log(" Waiting for approval…");
24
+
25
+ const deadline = Date.now() + (expires_in ?? 900) * 1000;
26
+ const pollMs = Math.max(2, interval ?? 5) * 1000;
27
+
28
+ for (;;) {
29
+ if (Date.now() > deadline) {
30
+ throw new CliError("expired", "Login timed out. Run `nomac login` again.");
31
+ }
32
+ await sleep(pollMs);
33
+ const r = await apiPublic("POST", "/api/v1/device/token", { device_code });
34
+ const status = r.json?.status;
35
+ if (status === "approved" && r.json.api_key) {
36
+ saveApiKey(r.json.api_key);
37
+ const me = await api("GET", "/api/v1/me");
38
+ console.log(`\n✅ connected — org "${me.org.name}" (${me.org.id})`);
39
+ return;
40
+ }
41
+ if (status === "denied") throw new CliError("denied", "Login was denied in the dashboard.");
42
+ if (status === "expired") throw new CliError("expired", "Login expired. Run `nomac login` again.");
43
+ if (status === "claimed") {
44
+ throw new CliError("claimed", "That login was already completed. Run `nomac login` again.");
45
+ }
46
+ // "pending" / "slow_down" → keep polling
47
+ }
48
+ }
package/src/mcp.mjs ADDED
@@ -0,0 +1,311 @@
1
+ // nomac mcp — stdio MCP server (JSON-RPC 2.0, newline-delimited).
2
+ // Thin wrappers over the nomac REST API; API-key auth (nomac login / env).
3
+ // Works in every MCP client day one; the hosted transport is mcp.nomac.app.
4
+
5
+ import { createInterface } from "node:readline";
6
+ import { api } from "./api.mjs";
7
+ import { loadProjectState, saveProjectState } from "./config.mjs";
8
+ import { packTarball } from "./pack.mjs";
9
+
10
+ const PROTOCOL_FALLBACK = "2025-06-18";
11
+
12
+ const text = (obj) => ({
13
+ content: [{ type: "text", text: typeof obj === "string" ? obj : JSON.stringify(obj, null, 2) }],
14
+ });
15
+ const errText = (msg) => ({ ...text(msg), isError: true });
16
+
17
+ async function resolveProjectId(args) {
18
+ if (args?.project_id) return args.project_id;
19
+ const state = loadProjectState(args?.directory ?? process.cwd());
20
+ if (state.project_id) return state.project_id;
21
+ throw new Error("no project here — run push_project first (or pass project_id)");
22
+ }
23
+
24
+ export const TOOLS = [
25
+ {
26
+ name: "connect_status",
27
+ description:
28
+ "Check the org's Apple connection health (API key validity, cert expiry, webhook). Start here if anything store-related fails.",
29
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
30
+ handler: async () => {
31
+ const me = await api("GET", "/api/v1/me");
32
+ const conns = await api("GET", "/api/v1/connections");
33
+ if (conns.connections.length === 0) {
34
+ return text({
35
+ org: me.org,
36
+ connections: [],
37
+ next_step:
38
+ "No Apple connection. The human connects once with an App Store Connect API key (dashboard → Connections, ~60s).",
39
+ });
40
+ }
41
+ const detail = await api("GET", `/api/v1/connections/${conns.connections[0].id}`);
42
+ return text({ org: me.org, connection: detail });
43
+ },
44
+ },
45
+ {
46
+ name: "push_project",
47
+ description:
48
+ "Pack the project directory (respects .gitignore; secrets never leave the machine) and upload a snapshot. Auto-detects the Xcode project, scheme and bundle id, runs source-level review lint, and links the App Store Connect app record. Run after every code change, before build.",
49
+ inputSchema: {
50
+ type: "object",
51
+ properties: {
52
+ directory: { type: "string", description: "project directory (default: cwd)" },
53
+ },
54
+ additionalProperties: false,
55
+ },
56
+ handler: async (args) => {
57
+ const dir = args?.directory ?? process.cwd();
58
+ let state = loadProjectState(dir);
59
+ if (!state.project_id) {
60
+ const name = dir.split("/").filter(Boolean).pop() ?? "app";
61
+ const created = await api("POST", "/api/v1/projects", { body: { name } });
62
+ state = { project_id: created.id, name };
63
+ saveProjectState(state, dir);
64
+ }
65
+ const { tarball, files } = await packTarball(dir);
66
+ const res = await api("POST", `/api/v1/projects/${state.project_id}/snapshots`, {
67
+ raw: tarball,
68
+ });
69
+ saveProjectState({ ...state, last_snapshot: res.snapshot_id }, dir);
70
+ return text({ project_id: state.project_id, files_packed: files.length, ...res });
71
+ },
72
+ },
73
+ {
74
+ name: "build",
75
+ description:
76
+ "Start a build. workflow=release (signed, uploads to TestFlight; costs one build from the plan quota) or smoke (unsigned compile check, fastest way to verify the project builds on macOS). Returns a build_id — poll with status.",
77
+ inputSchema: {
78
+ type: "object",
79
+ properties: {
80
+ project_id: { type: "string" },
81
+ workflow: { type: "string", enum: ["release", "smoke"], default: "release" },
82
+ },
83
+ additionalProperties: false,
84
+ },
85
+ handler: async (args) => {
86
+ const project_id = await resolveProjectId(args);
87
+ const res = await api("POST", "/api/v1/builds", {
88
+ body: { project_id, workflow: args?.workflow ?? "release" },
89
+ idempotencyKey: `mcp-${Date.now()}-${Math.random().toString(36).slice(2)}`,
90
+ });
91
+ return text({
92
+ ...res,
93
+ note: "poll `status` every ~30s; a release build reaching `ready` means the phone can install it from TestFlight",
94
+ });
95
+ },
96
+ },
97
+ {
98
+ name: "status",
99
+ description: "Build status (state machine position). States: queued→mirrored→dispatched→building→uploading→processing→ready|failed.",
100
+ inputSchema: {
101
+ type: "object",
102
+ properties: { build_id: { type: "string", description: "defaults to the last build started here" } },
103
+ additionalProperties: false,
104
+ },
105
+ handler: async (args) => {
106
+ const id = args?.build_id ?? loadProjectState().last_build;
107
+ if (!id) return errText("no build_id given and no build started from this directory");
108
+ return text(await api("GET", `/api/v1/builds/${id}`));
109
+ },
110
+ },
111
+ {
112
+ name: "get_failure",
113
+ description:
114
+ "Structured failure detail for a failed build: {stage, code, kind, error_lines, hints, raw_excerpt}. kind=project → you fix the source and re-push; kind=account → relay the deep link to your human.",
115
+ inputSchema: {
116
+ type: "object",
117
+ properties: { build_id: { type: "string" } },
118
+ required: ["build_id"],
119
+ additionalProperties: false,
120
+ },
121
+ handler: async (args) => text(await api("GET", `/api/v1/builds/${args.build_id}/failure`)),
122
+ },
123
+ {
124
+ name: "review_lint",
125
+ description:
126
+ "Review-readiness report (deterministic checks, graded green/yellow/red). Red findings block publish. Findings carry evidence + fix hints; some include ready-to-apply source patches (e.g. a generated PrivacyInfo.xcprivacy). Signals for fuzzy rules (4.3 distinctiveness) are evidence for YOU to judge.",
127
+ inputSchema: {
128
+ type: "object",
129
+ properties: { project_id: { type: "string" } },
130
+ additionalProperties: false,
131
+ },
132
+ handler: async (args) =>
133
+ text(await api("POST", "/api/v1/review-lint", { body: { project_id: await resolveProjectId(args) } })),
134
+ },
135
+ {
136
+ name: "get_metadata_schema",
137
+ description: "Field list, char limits, locales and exact screenshot dimensions for set_metadata / upload_screenshots.",
138
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
139
+ handler: async () => text(await api("GET", "/api/v1/metadata-schema")),
140
+ },
141
+ {
142
+ name: "get_metadata",
143
+ description: "Current App Store metadata (app + latest version localizations) from App Store Connect.",
144
+ inputSchema: {
145
+ type: "object",
146
+ properties: { project_id: { type: "string" } },
147
+ additionalProperties: false,
148
+ },
149
+ handler: async (args) =>
150
+ text(await api("GET", `/api/v1/projects/${await resolveProjectId(args)}/metadata`)),
151
+ },
152
+ {
153
+ name: "set_metadata",
154
+ description:
155
+ "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'.",
156
+ inputSchema: {
157
+ type: "object",
158
+ properties: {
159
+ project_id: { type: "string" },
160
+ locale: { type: "string", default: "en-US" },
161
+ fields: { type: "object" },
162
+ primary_category: { type: "string" },
163
+ age_rating: { type: "object" },
164
+ content_rights: { type: "string" },
165
+ copyright: { type: "string" },
166
+ review_contact: { type: "object" },
167
+ price: { type: "string", enum: ["FREE"] },
168
+ },
169
+ additionalProperties: false,
170
+ },
171
+ handler: async (args) => {
172
+ const { project_id: _p, ...rest } = args ?? {};
173
+ const project_id = await resolveProjectId(args);
174
+ return text(await api("PUT", `/api/v1/projects/${project_id}/metadata`, { body: rest }));
175
+ },
176
+ },
177
+ {
178
+ name: "upload_screenshots",
179
+ description:
180
+ "Replace one display type's screenshot set (all-or-nothing swap; exact dimensions validated first — see get_metadata_schema). images: [{filename, data: base64 PNG}].",
181
+ inputSchema: {
182
+ type: "object",
183
+ properties: {
184
+ project_id: { type: "string" },
185
+ display_type: { type: "string" },
186
+ locale: { type: "string", default: "en-US" },
187
+ images: { type: "array", items: { type: "object" } },
188
+ },
189
+ required: ["display_type", "images"],
190
+ additionalProperties: false,
191
+ },
192
+ handler: async (args) => {
193
+ const project_id = await resolveProjectId(args);
194
+ const { project_id: _p, ...rest } = args;
195
+ return text(await api("POST", `/api/v1/projects/${project_id}/screenshots`, { body: rest }));
196
+ },
197
+ },
198
+ {
199
+ name: "publish",
200
+ description:
201
+ "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.",
202
+ inputSchema: {
203
+ type: "object",
204
+ properties: {
205
+ project_id: { type: "string" },
206
+ confirm: { type: "boolean", default: false },
207
+ force: { type: "boolean", default: false, description: "override a red lint gate (not recommended)" },
208
+ },
209
+ additionalProperties: false,
210
+ },
211
+ handler: async (args) => {
212
+ const project_id = await resolveProjectId(args);
213
+ return text(
214
+ await api("POST", "/api/v1/publish", {
215
+ body: { project_id, confirm: args?.confirm ?? false, force: args?.force ?? false },
216
+ idempotencyKey: `mcp-pub-${project_id}-${args?.confirm ? "confirm" : "stage"}`,
217
+ }),
218
+ );
219
+ },
220
+ },
221
+ {
222
+ name: "get_feedback",
223
+ description:
224
+ "TestFlight crashes + tester feedback (the human-on-a-real-device return channel). Read after the human has tried the build; crashes include device/os and a crash log URL.",
225
+ inputSchema: {
226
+ type: "object",
227
+ properties: { project_id: { type: "string" } },
228
+ additionalProperties: false,
229
+ },
230
+ handler: async (args) =>
231
+ text(await api("GET", `/api/v1/projects/${await resolveProjectId(args)}/feedback`)),
232
+ },
233
+ {
234
+ name: "report_issue",
235
+ description:
236
+ "File an unknown/unfixable error with nomac support. Auto-attaches build error, logs (redacted) and lint state when you pass ref_id (a bld_… id). Deduped by error fingerprint. Read replies by calling with no description.",
237
+ inputSchema: {
238
+ type: "object",
239
+ properties: {
240
+ description: { type: "string", description: "what went wrong + what you tried (≥10 chars). Omit to read existing tickets/replies." },
241
+ ref_id: { type: "string" },
242
+ },
243
+ additionalProperties: false,
244
+ },
245
+ handler: async (args) => {
246
+ if (!args?.description) return text(await api("GET", "/api/v1/report-issue"));
247
+ return text(await api("POST", "/api/v1/report-issue", { body: args }));
248
+ },
249
+ },
250
+ ];
251
+
252
+ export async function runMcpServer() {
253
+ const rl = createInterface({ input: process.stdin, terminal: false });
254
+ const send = (msg) => process.stdout.write(JSON.stringify(msg) + "\n");
255
+
256
+ rl.on("line", async (line) => {
257
+ if (!line.trim()) return;
258
+ let req;
259
+ try {
260
+ req = JSON.parse(line);
261
+ } catch {
262
+ return; // ignore garbage
263
+ }
264
+ const { id, method, params } = req;
265
+ const reply = (result) => id !== undefined && send({ jsonrpc: "2.0", id, result });
266
+ const fail = (code, message) => id !== undefined && send({ jsonrpc: "2.0", id, error: { code, message } });
267
+
268
+ try {
269
+ switch (method) {
270
+ case "initialize":
271
+ reply({
272
+ protocolVersion: params?.protocolVersion ?? PROTOCOL_FALLBACK,
273
+ capabilities: { tools: {} },
274
+ serverInfo: { name: "nomac", version: "0.1.0" },
275
+ });
276
+ break;
277
+ case "notifications/initialized":
278
+ case "initialized":
279
+ break; // notification, no reply
280
+ case "ping":
281
+ reply({});
282
+ break;
283
+ case "tools/list":
284
+ reply({
285
+ tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })),
286
+ });
287
+ break;
288
+ case "tools/call": {
289
+ const tool = TOOLS.find((t) => t.name === params?.name);
290
+ if (!tool) return fail(-32602, `unknown tool: ${params?.name}`);
291
+ try {
292
+ reply(await tool.handler(params?.arguments ?? {}));
293
+ } catch (err) {
294
+ reply({
295
+ content: [{ type: "text", text: err?.friendly ?? err?.message ?? String(err) }],
296
+ isError: true,
297
+ });
298
+ }
299
+ break;
300
+ }
301
+ default:
302
+ if (id !== undefined) fail(-32601, `method not found: ${method}`);
303
+ }
304
+ } catch (err) {
305
+ fail(-32603, err?.message ?? "internal error");
306
+ }
307
+ });
308
+
309
+ // keep the process alive until stdin closes
310
+ await new Promise((resolve) => rl.on("close", resolve));
311
+ }
package/src/pack.mjs ADDED
@@ -0,0 +1,67 @@
1
+ import { readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ import ignoreFactory from "ignore";
4
+ import * as tar from "tar";
5
+
6
+ // Pack the working tree: respect .gitignore, always exclude the deny-list
7
+ // (secrets never leave the machine), gzip in memory.
8
+
9
+ const ALWAYS_IGNORE = [
10
+ ".git/",
11
+ "node_modules/",
12
+ ".nomac.json",
13
+ "*.p8",
14
+ "*.p12",
15
+ "*.mobileprovision",
16
+ "*.pem",
17
+ ".env",
18
+ ".env.*",
19
+ ".DS_Store",
20
+ "build/",
21
+ "DerivedData/",
22
+ "*.ipa",
23
+ "*.xcarchive",
24
+ ];
25
+
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
+
34
+ const files = [];
35
+ const walk = (current) => {
36
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
37
+ const full = join(current, entry.name);
38
+ const rel = relative(dir, full);
39
+ const relForIgnore = entry.isDirectory() ? `${rel}/` : rel;
40
+ if (ig.ignores(relForIgnore)) continue;
41
+ if (entry.isDirectory()) walk(full);
42
+ else if (entry.isFile()) files.push(rel);
43
+ }
44
+ };
45
+ walk(dir);
46
+ return files.sort();
47
+ }
48
+
49
+ export async function packTarball(dir) {
50
+ const files = collectFiles(dir);
51
+ if (files.length === 0) {
52
+ throw new Error("nothing to pack (is this the right directory?)");
53
+ }
54
+ const chunks = [];
55
+ await new Promise((resolve, reject) => {
56
+ tar
57
+ .create({ gzip: true, cwd: dir, portable: true }, files)
58
+ .on("data", (c) => chunks.push(c))
59
+ .on("end", resolve)
60
+ .on("error", reject);
61
+ });
62
+ return { tarball: Buffer.concat(chunks), files };
63
+ }
64
+
65
+ export function totalSize(dir, files) {
66
+ return files.reduce((n, f) => n + statSync(join(dir, f)).size, 0);
67
+ }