@nomac/cli 0.2.4 → 0.3.0-beta.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
@@ -2,6 +2,9 @@
2
2
  import { main } from "../src/cli.mjs";
3
3
 
4
4
  main(process.argv.slice(2)).catch((err) => {
5
- console.error(err?.friendly ?? err?.message ?? String(err));
5
+ const args = process.argv.slice(2);
6
+ if (["start", "sync", "pull", "stop", "session", "transport"].includes(args[0]) && args.includes("--json")) {
7
+ console.error(JSON.stringify({ error: { code: err?.code ?? "session_error", message: err?.friendly ?? err?.message ?? String(err), ...(err?.details ? { details: err.details } : {}) } }));
8
+ } else console.error(err?.friendly ?? err?.message ?? String(err));
6
9
  process.exit(err?.exitCode ?? 1);
7
10
  });
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ // Private rsync transport. stdout is the binary rsync protocol, never CLI logging.
3
+ import { rsyncServerCommand, runProcess, sshArgs } from "../src/session-ssh.mjs";
4
+
5
+ try {
6
+ const session = JSON.parse(process.env.NOMAC_TRANSFER_SESSION ?? "null");
7
+ if (!session) throw new Error("This helper is started by nomac sync/pull.");
8
+ const command = rsyncServerCommand(session, process.argv.slice(2), session.remoteDirectory);
9
+ await runProcess("ssh", [...sshArgs(session), session.target, command], { inherit: true });
10
+ } catch (error) {
11
+ console.error(error.friendly ?? error.message);
12
+ process.exitCode = error.exitCode ?? 1;
13
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nomac/cli",
3
- "version": "0.2.4",
4
- "description": "Ship iOS apps to TestFlight and the App Store without a Mac — built for agents.",
3
+ "version": "0.3.0-beta.1",
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",
7
7
  "bin": {
@@ -20,7 +20,10 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "ignore": "^7.0.0",
23
- "tar": "^7.4.0"
23
+ "tar": "^7.4.0",
24
+ "ws": "^8.21.3",
25
+ "https-proxy-agent": "^9.1.0",
26
+ "proxy-from-env": "^2.1.0"
24
27
  },
25
28
  "devDependencies": {
26
29
  "@types/node": "^26.1.1",
package/skill/SKILL.md CHANGED
@@ -12,7 +12,7 @@ description: >
12
12
  # nomac — ship iOS without a Mac
13
13
 
14
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 —
15
+ review-readiness, writes store metadata, manages pricing/products and submits for review —
16
16
  via MCP tools (preferred) or the `nomac` CLI.
17
17
 
18
18
  ## Setup check
@@ -81,8 +81,9 @@ too, and use `get_screenshot_upload` to find or poll the existing operation.
81
81
  Fix issues before marking items resolved. Every rejected item must be resolved
82
82
  or removed before resubmitting. Removed items cannot be added back to that submission.
83
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.
84
+ app-version item in Pending Developer Release. Edit subscription/IAP products,
85
+ events and custom pages through the store catalog below. Apple/account/legal
86
+ decisions still require the relevant Apple workflow.
86
87
 
87
88
  ## External TestFlight
88
89
 
@@ -103,8 +104,56 @@ requires its own review and distribution:
103
104
  5. All actions preview without confirm and apply with confirm:true. Pending
104
105
  requests use their original key/body; get_store_operation recovers them.
105
106
  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.
107
+ when appropriate. Never guess. Encryption declarations/documents use the
108
+ store catalog; rejected beta messages require App Store Connect. Fix the cause
109
+ before resubmitting a rejected binary.
110
+
111
+ ## Pricing, products, customer reviews and the wider store API
112
+
113
+ 1. `get_store_capabilities` lists operations by category/search with next_offset.
114
+ Pass `operation` to get that operation's exact Apple request/query schema.
115
+ Categories: listing, releases, pricing, purchases, subscriptions, reviews,
116
+ marketing, assets, testflight, analytics, compliance, webhooks.
117
+ 2. `read_store` executes listed GET operations. Begin with an `apps_*` relationship
118
+ and `scope:[]` for the linked app. Every child has a reusable `scope`; copy it
119
+ into the next read/write. Keep the query when following `next_cursor`.
120
+ 3. `update_store` takes the operation, scope and exact JSON:API `body`. For creation,
121
+ scope the parent. Every existing relationship ID needs its own verified scope
122
+ in `references`; the target and its ancestors are already verified. Territories
123
+ and categories are global. Inline creates use local IDs such as `${price1}`.
124
+ 4. Preview without confirm. `confirm:true` applies real paid prices, availability,
125
+ products/offers, public responses, release settings or event subscriptions.
126
+ Honor the human's existing authorization; do not ask again for authorized work.
127
+ Do not invent prices, legal answers or factual claims in public replies.
128
+ 5. `customerReviewResponses_createInstance` replies to a public customer review.
129
+ It does not send a message to Apple reviewers. Read their actual messages in
130
+ App Store Connect; `prepare_review_reply` only drafts the response.
131
+ 6. Product management covers subscriptions/IAP versions, localization, prices,
132
+ availability, grace periods and offers. First IAP approval may require the
133
+ app version submission. Draft review submission items can attach products;
134
+ use `publish` / `update_review` to submit, resolve, resubmit and release.
135
+ 7. Events, custom pages, experiments, accessibility and phased releases use their
136
+ catalog operations. Apple still validates account permissions and valid states.
137
+ Analytics report segments supply download URLs; offer-code CSV exports return
138
+ bounded base64 downloads. Webhooks send app events to an owner-selected receiver;
139
+ that receiver is responsible for running an agent when events arrive.
140
+
141
+ `upload_store_asset` handles new Apple file resources: previews, screenshot files,
142
+ purchase images, supplemental review attachments and encryption documents. Choose
143
+ the asset create operation, provide its parent relationship/body, and pass a local
144
+ `file_path` (CLI, up to 500 MiB) or `data_base64` (hosted, up to 2 MiB). Apple also
145
+ has asset-specific limits. The tool reserves, uploads each requested byte range,
146
+ then commits its checksum. Keep the same request_key and original bytes to resume.
147
+ `uploaded:true` is not processing success: poll `read_store` until
148
+ `assetDeliveryState.state` is `COMPLETE`. FAILED requires addressing Apple's errors.
149
+ This adds a new file; use `upload_screenshots` for managed screenshot replacement.
150
+
151
+ Keep pending write keys and unchanged bodies. `get_store_operation` exposes captured
152
+ targets and receipts. Unknown Apple outcomes remain pending and block other writes
153
+ to the app until reconciled; never use a fresh key to bypass one. Corrected inputs
154
+ or a new intentional action require a fresh key. Correspondence, appeals, App Privacy
155
+ questionnaire answers and account-wide financial/admin APIs are not exposed.
156
+ This catalog does not run simulators or Xcode Cloud jobs.
108
157
 
109
158
  ## When stuck
110
159
 
package/src/api.mjs CHANGED
@@ -1,12 +1,13 @@
1
1
  import { loadApiKey, loadApiUrl } from "./config.mjs";
2
2
 
3
3
  export class CliError extends Error {
4
- constructor(message, friendly, status, code) {
4
+ constructor(message, friendly, status, code, details) {
5
5
  super(message);
6
6
  this.friendly = friendly ?? message;
7
7
  this.exitCode = 1;
8
8
  this.status = status;
9
9
  this.code = code;
10
+ this.details = details;
10
11
  }
11
12
  }
12
13
 
@@ -33,7 +34,7 @@ export async function api(method, path, { body, raw, idempotencyKey } = {}) {
33
34
  if (!key) {
34
35
  throw new CliError(
35
36
  "not logged in",
36
- "Not logged in. Run `nomac login <api-key>` (create a key in the dashboard) or set NOMAC_API_KEY.",
37
+ "Not logged in. Run `nomac login` and approve access in your browser, or set NOMAC_API_KEY.",
37
38
  );
38
39
  }
39
40
  const url = `${loadApiUrl()}${path}`;
@@ -49,13 +50,17 @@ export async function api(method, path, { body, raw, idempotencyKey } = {}) {
49
50
  ...(idempotencyKey ? { "idempotency-key": idempotencyKey } : {}),
50
51
  },
51
52
  body: raw ?? (body !== undefined ? JSON.stringify(body) : undefined),
53
+ }).catch(() => {
54
+ throw new CliError("Request outcome unknown", "The response was lost. Reconcile the previous operation before retrying; reuse its request_key.", undefined, "outcome_unknown", { code: "outcome_unknown", ...(idempotencyKey ? { request_key: idempotencyKey } : {}) });
55
+ });
56
+ const text = await res.text().catch(() => {
57
+ throw new CliError("Response body was lost", "Reconcile the previous operation before retrying; reuse its request_key.", res.status, "outcome_unknown", { code: "outcome_unknown", ...(idempotencyKey ? { request_key: idempotencyKey } : {}) });
52
58
  });
53
- const text = await res.text();
54
59
  let json;
55
60
  try {
56
61
  json = text ? JSON.parse(text) : {};
57
62
  } catch {
58
- throw new CliError(`API returned non-JSON (HTTP ${res.status}): ${text.slice(0, 200)}`, undefined, res.status);
63
+ throw new CliError(`API returned non-JSON (HTTP ${res.status})`, "The response could not be read. Reconcile the operation before retrying; reuse its request_key.", res.status, "bad_response", { code: "bad_response", ...(idempotencyKey ? { request_key: idempotencyKey } : {}) });
59
64
  }
60
65
  if (!res.ok) {
61
66
  const err = json?.error ?? {};
@@ -64,6 +69,7 @@ export async function api(method, path, { body, raw, idempotencyKey } = {}) {
64
69
  err.message ?? `API error (HTTP ${res.status})`,
65
70
  res.status,
66
71
  err.code,
72
+ { ...err, ...(idempotencyKey ? { request_key: idempotencyKey } : {}) },
67
73
  );
68
74
  }
69
75
  return json;
package/src/cli.mjs CHANGED
@@ -4,9 +4,30 @@ import { loadProjectState, saveApiKey, saveProjectState } from "./config.mjs";
4
4
  import { packTarball } from "./pack.mjs";
5
5
  import { uploadSourceSnapshot } from "./upload.mjs";
6
6
 
7
- const HELP = `nomac — ship iOS apps without a Mac (agent-first)
7
+ const HELP = `nomac — Mac workspaces for agents / iOS app publishing
8
8
 
9
- Usage:
9
+ Nomac 2.0 cloud Macs:
10
+ nomac credits weekly hours and plan checkout
11
+ nomac start allocate or reconnect to your Mac
12
+ nomac exec --request-key KEY -- command args
13
+ nomac job JOB_ID inspect a submitted command
14
+ nomac output JOB_ID read resumable command output
15
+ nomac start --help session limits and usage
16
+
17
+ Existing Mac workspaces:
18
+ nomac start --host user@mac connect a dedicated Mac you control
19
+ nomac sync . upload changed project files
20
+ nomac ssh open the full Mac shell
21
+ nomac pull ./output retrieve a remote directory
22
+ nomac session status --json show workspace and connection information
23
+ nomac stop detach locally (does not end a rental)
24
+ nomac start --help setup, exclusions and preview limitations
25
+
26
+ Nomac 2.0 transport probe:
27
+ nomac tunnel <session> SSH through the configured port-443 gateway
28
+ nomac transport --help HTTPS jobs, resumable output and binary probes
29
+
30
+ Existing publishing commands:
10
31
  nomac login connect this device (approve in the browser)
11
32
  nomac login <api-key> store an nmk_ key directly (CI / power users)
12
33
  nomac push pack this directory and upload a snapshot
@@ -22,6 +43,40 @@ Environment: NOMAC_API_KEY, NOMAC_API_URL`;
22
43
  export async function main(argv) {
23
44
  const [cmd, ...rest] = argv;
24
45
  switch (cmd) {
46
+ case "credits": {
47
+ const { creditsCommand } = await import("./mac-credits.mjs");
48
+ return creditsCommand(rest);
49
+ }
50
+ case "tunnel": {
51
+ if (rest.length !== 1) throw new CliError("Usage: nomac tunnel <session-id>");
52
+ const { tunnel } = await import("./mac-transport.mjs");
53
+ if (process.env.NOMAC_SESSION_TOKEN) return tunnel(rest[0]);
54
+ const { sessionAccess } = await import("./hosted-sessions.mjs");
55
+ const access = await sessionAccess(rest[0].replace(/^nomac-/, ""));
56
+ return tunnel(rest[0], { gateway: access.gateway, token: access.token });
57
+ }
58
+ case "transport": {
59
+ const { transportCommand } = await import("./transport-command.mjs");
60
+ return transportCommand(rest);
61
+ }
62
+ case "start":
63
+ case "exec":
64
+ case "job":
65
+ case "output":
66
+ case "sync":
67
+ case "ssh":
68
+ case "pull":
69
+ case "stop":
70
+ case "session": {
71
+ const { sessionStore } = await import("./session-store.mjs");
72
+ const store = sessionStore();
73
+ if (!rest.includes("--host") && !rest.some(value => value.startsWith("--host=")) && store.load()?.mode !== "external_ssh") {
74
+ const { hostedSessionCommand } = await import("./hosted-sessions.mjs");
75
+ return hostedSessionCommand(cmd, rest, { store });
76
+ }
77
+ const { sessionCommand } = await import("./sessions.mjs");
78
+ return sessionCommand(cmd, rest);
79
+ }
25
80
  case "login": {
26
81
  const key = rest[0];
27
82
  if (key) {
@@ -0,0 +1,171 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { chmodSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { parseArgs } from "node:util";
5
+ import { api, CliError } from "./api.mjs";
6
+ import { sessionStore } from "./session-store.mjs";
7
+ import { forwardingArg, runProcess, sshArgs, workspaceCommand } from "./session-ssh.mjs";
8
+ import { pullProject, syncProject } from "./session-transfer.mjs";
9
+ import { macRequest, sessionId, transportOptions } from "./mac-transport.mjs";
10
+
11
+ const HELP = `Nomac cloud Mac sessions
12
+ nomac start [--wait 300] [--max-duration 7200] [--idle-timeout 600] [--max-cost 5] [--json]
13
+ nomac sync . [--dry-run] upload changed files
14
+ nomac ssh [--forward 8080:3000] full Mac shell and port forwarding
15
+ nomac ssh -- command args run any command over SSH
16
+ nomac exec --request-key KEY -- command args
17
+ nomac job JOB_ID reconcile a submitted command
18
+ nomac output JOB_ID [--cursor 0] [--stream stdout|stderr]
19
+ nomac pull ./output [--from PATH] retrieve files before stopping
20
+ nomac session status --json live state, cost and cleanup status
21
+ nomac stop [--wait 60] --json request shutdown and wait for confirmation
22
+
23
+ Login once with nomac login. The CLI creates a local SSH key; it sends only the
24
+ public key. Sessions use the port-443 gateway and verified guest host keys.
25
+ Time continues while the VM is ready, including between commands. Inspect the
26
+ returned offer and limits. Stop removes the VM and its files; pull first.
27
+ A wait timeout leaves the session attached: inspect status or run stop.
28
+ Use nomac start --host user@mac to connect an existing Mac instead.`;
29
+ const options = {
30
+ help: { type: "boolean" }, json: { type: "boolean" }, wait: { type: "string" },
31
+ "max-duration": { type: "string" }, "idle-timeout": { type: "string" },
32
+ "max-cost": { type: "string" }, "request-key": { type: "string" }, timeout: { type: "string" },
33
+ overwrite: { type: "boolean" }, "dry-run": { type: "boolean" }, from: { type: "string" },
34
+ forward: { type: "string", multiple: true }, cursor: { type: "string" }, stream: { type: "string" },
35
+ };
36
+ function budget(value = "5") {
37
+ if (!/^(?:0|[1-9][0-9]{0,5})(?:\.[0-9]{1,6})?$/.test(value)) throw new CliError("--max-cost must be a positive USD amount, for example 5 or 2.50.");
38
+ const [whole, fraction = ""] = value.split(".");
39
+ const result = BigInt(whole) * 1000000n + BigInt(fraction.padEnd(6, "0"));
40
+ if (result <= 0n) throw new CliError("--max-cost must be positive.");
41
+ return result.toString();
42
+ }
43
+ function integer(value, fallback, min, max) {
44
+ if (value === undefined) return fallback;
45
+ if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) < min || Number(value) > max) throw new CliError("Invalid numeric option.");
46
+ return Number(value);
47
+ }
48
+ export async function sessionAccess(id, request = api) {
49
+ sessionId(id);
50
+ const access = await request("POST", `/api/v1/mac/sessions/${id}/access`);
51
+ if (access.session_id !== id || !Number.isSafeInteger(access.generation) || access.generation < 1 || access.ssh?.username !== "agent" || !/^ssh-ed25519 [A-Za-z0-9+/]+={0,2}(?: [^\r\n\x00]*)?$/.test(access.ssh?.host_key ?? "")) throw new CliError("Invalid session access response.");
52
+ // Validates the gateway origin and token without saving bearer credentials to disk.
53
+ transportOptions({ gateway: access.gateway, token: access.token });
54
+ return access;
55
+ }
56
+ export async function hostedRequest(id, method, path, params = {}, { request = api, transport = macRequest } = {}) {
57
+ const access = await sessionAccess(id, request);
58
+ return transport(method, path, { ...params, gateway: access.gateway, token: access.token });
59
+ }
60
+ export async function hostedSessionCommand(command, argv, { store = sessionStore(), request = api, transport = macRequest, run = runProcess, print = console.log, sleep = ms => new Promise(r => setTimeout(r, ms)), now = Date.now } = {}) {
61
+ let parsed;
62
+ try { parsed = parseArgs({ args: argv, options, allowPositionals: true }); }
63
+ catch (error) { throw new CliError(error.message); }
64
+ const { values, positionals } = parsed;
65
+ if (values.help) return print(HELP);
66
+ const output = result => {
67
+ const billing = result.session?.billing;
68
+ const usage = billing?.mode === "weekly_included"
69
+ ? `Included time used: ${billing.used_seconds} seconds. No extra charge.`
70
+ : `Cost: ${JSON.stringify(result.session?.cost)}`;
71
+ print(values.json || !["start", "stop"].includes(command) ? JSON.stringify(result, null, 2) : `${result.session.id}: ${result.session.state}\n${result.session.state === "ready" ? "Next: nomac sync . → nomac ssh" : "Check: nomac session status --json"}\n${usage}`);
72
+ return result;
73
+ };
74
+ const saveView = (local, view) => {
75
+ if (view.id !== local.id && local.id) throw new CliError("Session response mismatch.");
76
+ sessionId(view.id);
77
+ Object.assign(local, { id: view.id, state: view.state, snapshot: view }); store.save(local);
78
+ return view;
79
+ };
80
+ const refresh = async local => saveView(local, (await request("GET", `/api/v1/mac/sessions/${sessionId(local.id)}`)).session);
81
+ async function ready(local) {
82
+ await refresh(local);
83
+ if (local.state !== "ready") throw new CliError("Mac is not ready.", `Session ${local.id} is ${local.state}. Run nomac session status --json.`, undefined, "session_not_ready");
84
+ const access = await sessionAccess(local.id, request);
85
+ if (local.generation && (local.generation !== access.generation || local.hostKey !== access.ssh.host_key)) throw new CliError("The guest identity changed. Stop this session and start a fresh one.", undefined, undefined, "host_key_changed");
86
+ Object.assign(local, { generation: access.generation, hostKey: access.ssh.host_key, target: `agent@nomac-${local.id}`, workspace: "/Users/agent/workspace", knownHosts: join(store.directory, "known_hosts") });
87
+ writeFileSync(local.knownHosts, `nomac-${local.id} ${access.ssh.host_key}\n`, { mode: 0o600 }); chmodSync(local.knownHosts, 0o600); store.save(local);
88
+ return local;
89
+ }
90
+ const localSession = () => {
91
+ const local = store.load();
92
+ if (local?.mode !== "hosted" || !local.id) throw new CliError("No attached cloud session.", "Run nomac start to allocate or reconcile your Mac.");
93
+ return local;
94
+ };
95
+ if (command === "session") {
96
+ if (positionals.length !== 1 || !["status", "info"].includes(positionals[0])) throw new CliError("Usage: nomac session status --json");
97
+ const local = localSession(); return output({ session: await refresh(local) });
98
+ }
99
+ if (["exec", "job", "output"].includes(command)) {
100
+ const local = localSession(), base = `/api/v1/mac/sessions/${sessionId(local.id)}`;
101
+ let path, method = "GET", params = {};
102
+ if (command === "exec") {
103
+ if (!positionals.length || !argv.includes("--") || !/^[A-Za-z0-9_.:-]{8,128}$/.test(values["request-key"] ?? "")) throw new CliError("Use nomac exec --request-key STABLE_KEY -- command args");
104
+ method = "POST"; path = `${base}/jobs`; params = { requestKey: values["request-key"], body: { argv: positionals, timeout_seconds: integer(values.timeout, 60, 1, 600) } };
105
+ } else {
106
+ if (positionals.length !== 1 || !/^job_[a-f0-9]{40}$/.test(positionals[0])) throw new CliError("Supply the existing job ID.");
107
+ path = `${base}/jobs/${positionals[0]}`;
108
+ if (command === "output") {
109
+ const cursor = integer(values.cursor, 0, 0, Number.MAX_SAFE_INTEGER), stream = values.stream ?? "stdout";
110
+ if (!["stdout", "stderr"].includes(stream)) throw new CliError("Use stdout or stderr.");
111
+ path += `/output?cursor=${cursor}&stream=${stream}`;
112
+ }
113
+ }
114
+ return output(await hostedRequest(local.id, method, path, params, { request, transport }));
115
+ }
116
+ if (command === "ssh") {
117
+ if (values.json) throw new CliError("SSH streams bytes. Use session status --json for metadata.");
118
+ const local = await ready(localSession());
119
+ return run("ssh", [...sshArgs(local, { batch: false }), ...(!positionals.length && process.stdin.isTTY ? ["-t"] : []), ...(values.forward ?? []).flatMap(v => ["-L", forwardingArg(v)]), "-o", "ExitOnForwardFailure=yes", local.target, workspaceCommand(local, positionals)], { inherit: true });
120
+ }
121
+ return store.locked(async () => {
122
+ if (command === "start") {
123
+ if (positionals.length) throw new CliError("Usage: nomac start [--wait SECONDS] [--json]");
124
+ let local = store.load();
125
+ if (local && local.mode !== "hosted") throw new CliError("An existing Mac workspace is attached.");
126
+ 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"]) };
127
+ if (local && !["stopped", "failed"].includes(local.state)) {
128
+ for (const [flag, field] of [["max-duration", "max_duration_seconds"], ["idle-timeout", "idle_timeout_seconds"], ["max-cost", "spend_cap_microunits"]]) {
129
+ 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.");
130
+ }
131
+ if (values["request-key"] !== undefined && values["request-key"] !== local.requestKey) throw new CliError("A session is already attached under a different request key. Inspect it or stop it before starting another.");
132
+ }
133
+ if (!local || ["stopped", "failed"].includes(local.state)) {
134
+ const identity = join(store.directory, `id_ed25519_${randomUUID()}`);
135
+ 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 } };
137
+ store.save(local); store.saveBaseline({});
138
+ }
139
+ if (!local.id) {
140
+ const result = await request("POST", "/api/v1/mac/sessions", { idempotencyKey: local.requestKey, body: local.request });
141
+ saveView(local, result.session);
142
+ }
143
+ const deadline = now() + integer(values.wait, 300, 0, 600) * 1000;
144
+ for (;;) {
145
+ await refresh(local);
146
+ if (!["queued", "provisioning"].includes(local.state) || now() >= deadline) break;
147
+ await sleep(2000);
148
+ }
149
+ if (local.state === "ready") await ready(local);
150
+ return output({ session: local.snapshot, request_key: local.requestKey });
151
+ }
152
+ const local = localSession();
153
+ if (command === "stop") {
154
+ if (positionals.length) throw new CliError("Usage: nomac stop [--wait SECONDS] [--json]");
155
+ saveView(local, (await request("DELETE", `/api/v1/mac/sessions/${sessionId(local.id)}`)).session);
156
+ const deadline = now() + integer(values.wait, 60, 0, 300) * 1000;
157
+ while (!["stopped", "failed"].includes(local.state) && now() < deadline) { await sleep(1000); await refresh(local); }
158
+ return output({ session: local.snapshot });
159
+ }
160
+ await ready(local);
161
+ if (command === "sync") {
162
+ if (positionals.length > 1 || (positionals[0] && resolve(positionals[0]) !== store.project)) throw new CliError("Run nomac sync . from the attached project.");
163
+ return output(await syncProject(local, store, { overwrite: values.overwrite, dryRun: values["dry-run"], run }));
164
+ }
165
+ if (command === "pull") {
166
+ if (positionals.length !== 1) throw new CliError("Usage: nomac pull LOCAL_PATH [--from REMOTE_PATH]");
167
+ return output(await pullProject(local, store, positionals[0], { from: values.from ?? positionals[0], overwrite: values.overwrite, dryRun: values["dry-run"], run }));
168
+ }
169
+ throw new CliError("Unknown cloud session command.");
170
+ });
171
+ }