@nomac/cli 0.3.0-beta.1 → 0.3.1

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 CHANGED
@@ -3,7 +3,7 @@ import { main } from "../src/cli.mjs";
3
3
 
4
4
  main(process.argv.slice(2)).catch((err) => {
5
5
  const args = process.argv.slice(2);
6
- if (["start", "sync", "pull", "stop", "session", "transport"].includes(args[0]) && args.includes("--json")) {
6
+ if (["start", "sync", "pull", "stop", "session", "transport", "exec", "job", "output"].includes(args[0]) && args.includes("--json")) {
7
7
  console.error(JSON.stringify({ error: { code: err?.code ?? "session_error", message: err?.friendly ?? err?.message ?? String(err), ...(err?.details ? { details: err.details } : {}) } }));
8
8
  } else console.error(err?.friendly ?? err?.message ?? String(err));
9
9
  process.exit(err?.exitCode ?? 1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nomac/cli",
3
- "version": "0.3.0-beta.1",
3
+ "version": "0.3.1",
4
4
  "description": "Cloud Mac sessions over SSH and HTTPS, plus iOS builds and publishing — built for agents.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
package/skill/SKILL.md CHANGED
@@ -1,15 +1,44 @@
1
1
  ---
2
2
  name: nomac
3
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).
4
+ Use a full cloud Mac over SSH, HTTPS or MCP for macOS tools, Xcode and iOS
5
+ development. Start sessions, sync files, run commands, retrieve work and
6
+ stop. Also supports the separate iOS Builder product for TestFlight and
7
+ App Store publishing, signing, screenshots, metadata and review replies.
10
8
  ---
11
9
 
12
- # nomac — ship iOS without a Mac
10
+ # nomac — a Mac for your agent
11
+
12
+ NoMac 2.0 provides a full macOS VM with administrator access. You choose the
13
+ commands and tools. A weekly Mac subscription is separate from Builder.
14
+
15
+ ## Cloud Mac setup and workflow
16
+
17
+ 1. Sign in at https://nomac.app/mac. The human chooses $5/week for 5 hours
18
+ or $20/week for 25 hours. Verify with `get_mac_credits`; a checkout URL
19
+ is not a payment. No Apple Developer account is needed for an unsigned build.
20
+ 2. Install `npm install -g @nomac/cli`, run `nomac login`, and give the human
21
+ the printed device URL and code. MCP: `claude mcp add nomac -- npx @nomac/cli mcp`.
22
+ Hosted MCP: https://mcp.nomac.app/mcp with `Authorization: Bearer <nmk_key>`.
23
+ 3. `nomac start --json`, wait until ready, `nomac sync .`, then `nomac ssh`.
24
+ For managed commands: `nomac exec --request-key stable-key-0001 -- COMMAND`,
25
+ then `nomac job JOB_ID` and `nomac output JOB_ID`. MCP equivalents are
26
+ `start_mac`, `exec_mac`, `get_mac_job`, `read_mac_output`, `stop_mac`.
27
+ 4. Download work with `nomac pull ./build` (use `--from PATH` for another
28
+ remote directory), then `nomac stop --json`. Confirm `cleanup_confirmed`.
29
+
30
+ Stopping deletes files and logs. Download before stopping. SSH disconnects
31
+ do not stop the session. Ready time includes idle time; queue/setup are free.
32
+ Unused hours expire weekly. One session at a time; no automatic overage.
33
+ Keep the same request key and arguments after uncertain responses; inspect
34
+ the original job before retrying. Output retrieval failure is not job failure.
35
+ Managed jobs have a 600-second limit; SSH can run longer within session limits.
36
+ CLI sync skips symlinks and preserves remote extra files; use native SSH/rsync
37
+ when needed. GUI/simulator control and persistent disks are not currently offered.
38
+ Report failures using `report_issue` with `session_id`, optional `job_id` and
39
+ a description without secrets. Read https://nomac.app/llms.txt for full limits.
40
+
41
+ ## Separate Builder product
13
42
 
14
43
  nomac is the hands; you are the brain. It builds, signs, uploads, lints for
15
44
  review-readiness, writes store metadata, manages pricing/products and submits for review —
@@ -45,6 +45,12 @@ function integer(value, fallback, min, max) {
45
45
  if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) < min || Number(value) > max) throw new CliError("Invalid numeric option.");
46
46
  return Number(value);
47
47
  }
48
+ const validRequestKey = value => typeof value === "string" && /^[A-Za-z0-9_.:-]{8,128}$/.test(value);
49
+ // Only explicit pre-allocation rejections permit a fresh intent. A timeout,
50
+ // unreadable response or idempotency conflict may still own a real session.
51
+ const startRejections = new Set(["invalid_request", "invalid_public_key", "profile_unavailable", "session_cap_too_low",
52
+ "weekly_time_unavailable", "weekly_time_expired", "insufficient_compute_credit", "compute_account_blocked",
53
+ "session_limit_reached", "organization_not_found", "compute_unavailable", "unauthenticated", "invalid_origin"]);
48
54
  export async function sessionAccess(id, request = api) {
49
55
  sessionId(id);
50
56
  const access = await request("POST", `/api/v1/mac/sessions/${id}/access`);
@@ -63,6 +69,8 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
63
69
  catch (error) { throw new CliError(error.message); }
64
70
  const { values, positionals } = parsed;
65
71
  if (values.help) return print(HELP);
72
+ const wait = ["start", "stop"].includes(command) ? integer(values.wait, command === "start" ? 300 : 60, 0, command === "start" ? 600 : 300) : 0;
73
+ if (command === "start" && values["request-key"] !== undefined && !validRequestKey(values["request-key"])) throw new CliError("Use a request key of 8–128 letters, digits, underscores, periods, colons or hyphens.");
66
74
  const output = result => {
67
75
  const billing = result.session?.billing;
68
76
  const usage = billing?.mode === "weekly_included"
@@ -124,6 +132,9 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
124
132
  let local = store.load();
125
133
  if (local && local.mode !== "hosted") throw new CliError("An existing Mac workspace is attached.");
126
134
  const limits = { max_duration_seconds: integer(values["max-duration"], 7200, 60, 86400), idle_timeout_seconds: integer(values["idle-timeout"], 600, 60, 3600), spend_cap_microunits: budget(values["max-cost"]) };
135
+ // Older CLIs persisted invalid keys before contacting the API. Such a key
136
+ // cannot pass server validation and therefore cannot have allocated a VM.
137
+ if (local && !local.id && !validRequestKey(local.requestKey)) { local.state = "failed"; store.save(local); }
127
138
  if (local && !["stopped", "failed"].includes(local.state)) {
128
139
  for (const [flag, field] of [["max-duration", "max_duration_seconds"], ["idle-timeout", "idle_timeout_seconds"], ["max-cost", "spend_cap_microunits"]]) {
129
140
  if (values[flag] !== undefined && limits[field] !== local.request.limits[field]) throw new CliError("This session already has different limits. Stop it and start a fresh session to change them.");
@@ -133,14 +144,23 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
133
144
  if (!local || ["stopped", "failed"].includes(local.state)) {
134
145
  const identity = join(store.directory, `id_ed25519_${randomUUID()}`);
135
146
  await run("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-C", "nomac-session", "-f", identity]);
136
- local = { mode: "hosted", state: "requesting", identity, requestKey: values["request-key"] ?? `cli-${randomUUID()}`, request: { profile: "standard", public_key: readFileSync(`${identity}.pub`, "utf8").trim(), limits } };
147
+ local = { mode: "hosted", state: "requesting", outcomeUnknown: false, identity, requestKey: values["request-key"] ?? `cli-${randomUUID()}`, request: { profile: "standard", public_key: readFileSync(`${identity}.pub`, "utf8").trim(), limits } };
137
148
  store.save(local); store.saveBaseline({});
138
149
  }
139
150
  if (!local.id) {
140
- const result = await request("POST", "/api/v1/mac/sessions", { idempotencyKey: local.requestKey, body: local.request });
151
+ const previouslyUncertain = local.outcomeUnknown !== false;
152
+ local.outcomeUnknown = true; store.save(local); // A crash after dispatch must reconcile this intent.
153
+ let result;
154
+ try { result = await request("POST", "/api/v1/mac/sessions", { idempotencyKey: local.requestKey, body: local.request }); }
155
+ catch (error) {
156
+ if (!previouslyUncertain && startRejections.has(error.code) && Number.isInteger(error.status) && error.status >= 400 && error.status <= 503) {
157
+ local.state = "failed"; local.rejection = error.code; store.save(local);
158
+ }
159
+ throw error;
160
+ }
141
161
  saveView(local, result.session);
142
162
  }
143
- const deadline = now() + integer(values.wait, 300, 0, 600) * 1000;
163
+ const deadline = now() + wait * 1000;
144
164
  for (;;) {
145
165
  await refresh(local);
146
166
  if (!["queued", "provisioning"].includes(local.state) || now() >= deadline) break;
@@ -153,7 +173,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
153
173
  if (command === "stop") {
154
174
  if (positionals.length) throw new CliError("Usage: nomac stop [--wait SECONDS] [--json]");
155
175
  saveView(local, (await request("DELETE", `/api/v1/mac/sessions/${sessionId(local.id)}`)).session);
156
- const deadline = now() + integer(values.wait, 60, 0, 300) * 1000;
176
+ const deadline = now() + wait * 1000;
157
177
  while (!["stopped", "failed"].includes(local.state) && now() < deadline) { await sleep(1000); await refresh(local); }
158
178
  return output({ session: local.snapshot });
159
179
  }
package/src/mac-tools.mjs CHANGED
@@ -14,5 +14,5 @@ export const MAC_TOOLS = [
14
14
  { name: "stop_mac", description: "Stop billing by shutting down the VM and deleting its files. Retrieve files first. Poll get_mac_session until cleanup_confirmed; stopping only acknowledges the request.", inputSchema: schema({ session_id: session }, ["session_id"]), handler: async args => text(await api("DELETE", base(args))) },
15
15
  { name: "exec_mac", description: "Run any argv command in your cloud Mac workspace. Returns a durable job ID; poll get_mac_job and read_mac_output. Reuse request_key and identical arguments after lost responses; never submit a new key blindly. Use bash -lc explicitly for shell syntax.", inputSchema: schema({ session_id: session, request_key: key, argv: { type: "array", items: { type: "string" }, minItems: 1 }, timeout_seconds: { type: "integer", minimum: 1, maximum: 600 } }, ["session_id", "request_key", "argv"]), handler: async ({ session_id, request_key, ...body }) => text(await api("POST", `${base({ session_id })}/jobs`, { body, idempotencyKey: request_key })) },
16
16
  { name: "get_mac_job", description: "Inspect the existing command without rerunning it. Retain its job ID until completion and retrieve output before stopping the VM.", inputSchema: schema({ session_id: session, job_id: job }, ["session_id", "job_id"]), handler: async args => text(await api("GET", `${base(args)}/jobs/${encodeURIComponent(args.job_id)}`)) },
17
- { name: "read_mac_output", description: "Read a bounded stdout/stderr chunk by byte cursor. data_base64 preserves binary output. Resume from next_cursor; retrieve before stopping the VM.", inputSchema: schema({ session_id: session, job_id: job, cursor: { type: "integer", minimum: 0 }, stream: { type: "string", enum: ["stdout", "stderr"] } }, ["session_id", "job_id"]), handler: async args => text(await api("GET", `${base(args)}/jobs/${encodeURIComponent(args.job_id)}/output?${new URLSearchParams({ cursor: String(args.cursor ?? 0), stream: args.stream ?? "stdout" })}`)) },
17
+ { name: "read_mac_output", description: "Read stdout/stderr as base64 chunks; resume from next_cursor. If truncated, the final response includes tail_base64 and its absolute tail_offset. Retrieve promptly: only recent completed jobs retain output. output_expired preserves the job receipt and never means rerun. Retrieve before stopping the VM.", inputSchema: schema({ session_id: session, job_id: job, cursor: { type: "integer", minimum: 0 }, stream: { type: "string", enum: ["stdout", "stderr"] } }, ["session_id", "job_id"]), handler: async args => text(await api("GET", `${base(args)}/jobs/${encodeURIComponent(args.job_id)}/output?${new URLSearchParams({ cursor: String(args.cursor ?? 0), stream: args.stream ?? "stdout" })}`)) },
18
18
  ];
package/src/mcp.mjs CHANGED
@@ -363,12 +363,14 @@ export const TOOLS = [
363
363
  {
364
364
  name: "report_issue",
365
365
  description:
366
- "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. An optional email requests a direct resolution update; replies are always available by calling with no description.",
366
+ "File an unknown/unfixable error with nomac support. For a Mac use session_id and optional job_id; for a build use ref_id (bld_…). Attaches account-scoped context and deduplicates repeats. An optional email requests a direct resolution update; replies are always available by calling with no description.",
367
367
  inputSchema: {
368
368
  type: "object",
369
369
  properties: {
370
370
  description: { type: "string", description: "what went wrong + what you tried (≥10 chars). Omit to read existing tickets/replies." },
371
371
  ref_id: { type: "string" },
372
+ session_id: { type: "string", pattern: "^ses_[A-Za-z0-9_-]{1,80}$" },
373
+ job_id: { type: "string", pattern: "^job_[a-f0-9]{40}$" },
372
374
  email: {
373
375
  type: "string",
374
376
  format: "email",