@nomac/cli 0.2.5 → 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.5",
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/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
+ }
@@ -0,0 +1,10 @@
1
+ import { parseArgs } from "node:util";
2
+ import { api, CliError } from "./api.mjs";
3
+ export async function creditsCommand(argv, { request = api, print = console.log } = {}) {
4
+ const { values, positionals } = parseArgs({ args: argv, options: { plan: { type: "string" }, "request-key": { type: "string" }, json: { type: "boolean" }, help: { type: "boolean" } }, allowPositionals: true });
5
+ if (values.help) return print("nomac credits [--json]\nnomac credits --plan five|twenty_five --request-key STABLE_KEY\n$5/week for 5 hours or $20/week for 25 hours. Unused hours expire weekly.\nReturns a subscription checkout link; the human reviews and pays. Reuse the key after a lost response.");
6
+ if (positionals.length) throw new CliError("Unexpected credits argument.");
7
+ if (!values.plan) return print(JSON.stringify(await request("GET", "/api/v1/mac/credits"), null, 2));
8
+ if (!["five", "twenty_five"].includes(values.plan) || !/^[A-Za-z0-9_.:-]{8,128}$/.test(values["request-key"] ?? "")) throw new CliError("Choose --plan five or twenty_five and supply a stable --request-key.");
9
+ return print(JSON.stringify(await request("POST", "/api/v1/mac/credits/checkout", { idempotencyKey: values["request-key"], body: { plan: values.plan } }), null, 2));
10
+ }
@@ -0,0 +1,18 @@
1
+ import { api } from "./api.mjs";
2
+ const text = value => ({ content: [{ type: "text", text: JSON.stringify(value, null, 2) }] });
3
+ const session = { type: "string", pattern: "^ses_[A-Za-z0-9_-]{1,80}$" };
4
+ const job = { type: "string", pattern: "^job_[a-f0-9]{40}$" };
5
+ const key = { type: "string", pattern: "^[A-Za-z0-9_.:-]{8,128}$" };
6
+ const schema = (properties, required = []) => ({ type: "object", properties, required, additionalProperties: false });
7
+ const base = args => `/api/v1/mac/sessions/${encodeURIComponent(args.session_id)}`;
8
+ export const MAC_TOOLS = [
9
+ { name: "get_mac_checkout", description: "Return a weekly subscription checkout: five is $5/week for 5 hours; twenty_five is $20/week for 25 hours. Renews until canceled; no rollover or overage. The human reviews and subscribes. Reuse request_key after a lost response.", inputSchema: schema({ plan: { type: "string", enum: ["five", "twenty_five"] }, request_key: key }, ["plan", "request_key"]), handler: async ({ plan, request_key }) => text(await api("POST", "/api/v1/mac/credits/checkout", { body: { plan }, idempotencyKey: request_key })) },
10
+ { name: "get_mac_credits", description: "Read weekly Mac hours, expiry, reservations and concurrency, separate from legacy build subscriptions.", inputSchema: schema({}), handler: async () => text(await api("GET", "/api/v1/mac/credits")) },
11
+ { name: "start_mac", description: "Allocate a full cloud Mac VM. Supply a stable request_key; SSH public_key is optional for HTTPS-only agents; reuse both after a lost response. Inspect the returned offer and limits. Poll get_mac_session until ready. No project pipeline is imposed; stop_mac deletes the VM and files.", inputSchema: schema({ public_key: { type: "string" }, request_key: key, limits: { type: "object", properties: { max_duration_seconds: { type: "integer", minimum: 60, maximum: 86400 }, idle_timeout_seconds: { type: "integer", minimum: 60, maximum: 3600 }, spend_cap_microunits: { type: "string", pattern: "^[1-9][0-9]{0,17}$" } }, additionalProperties: false } }, ["request_key"]), handler: async ({ request_key, ...body }) => text(await api("POST", "/api/v1/mac/sessions", { body, idempotencyKey: request_key })) },
12
+ { name: "list_mac_sessions", description: "Find existing Mac sessions, including after losing a response. Shows state, cost, limits and cleanup.", inputSchema: schema({}), handler: async () => text(await api("GET", "/api/v1/mac/sessions")) },
13
+ { name: "get_mac_session", description: "Read live Mac allocation state, cost, funded time and cleanup confirmation. queued/provisioning means wait; stopping is not yet confirmed cleanup.", inputSchema: schema({ session_id: session }, ["session_id"]), handler: async args => text(await api("GET", base(args))) },
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
+ { 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
+ { 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" })}`)) },
18
+ ];
@@ -0,0 +1,118 @@
1
+ import http from "node:http";
2
+ import https from "node:https";
3
+ import { HttpsProxyAgent } from "https-proxy-agent";
4
+ import { getProxyForUrl } from "proxy-from-env";
5
+ import WebSocket, { createWebSocketStream } from "ws";
6
+ import { CliError } from "./api.mjs";
7
+
8
+ export function sessionId(value) {
9
+ const id = String(value ?? "").replace(/^nomac-/, "");
10
+ if (!/^ses_[A-Za-z0-9_-]{1,80}$/.test(id)) throw new CliError("Invalid Mac session ID.", undefined, undefined, "invalid_session");
11
+ return id;
12
+ }
13
+
14
+ export function transportOptions(options = {}) {
15
+ let url;
16
+ try { url = new URL(options.gateway ?? process.env.NOMAC_GATEWAY_URL); }
17
+ catch { throw new CliError("Set NOMAC_GATEWAY_URL to the HTTPS gateway origin.", undefined, undefined, "gateway_required"); }
18
+ const loopback = ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname);
19
+ if ((url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) || url.username || url.password || url.search || url.hash || url.pathname !== "/") {
20
+ throw new CliError("The gateway must be an HTTPS origin; HTTP is allowed only for a loopback probe.", undefined, undefined, "invalid_gateway");
21
+ }
22
+ const token = options.token ?? process.env.NOMAC_SESSION_TOKEN;
23
+ if (typeof token !== "string" || !/^[A-Za-z0-9_-]{32,2048}$/.test(token)) throw new CliError("Set NOMAC_SESSION_TOKEN to a session-scoped access token.", undefined, undefined, "token_required");
24
+ const proxy = options.proxy ?? getProxyForUrl(url.href);
25
+ return { url, token, agent: proxy ? new HttpsProxyAgent(proxy) : undefined, ca: options.ca };
26
+ }
27
+
28
+ /** Bounded requests deliberately do not require streaming responses or retry mutations. */
29
+ export function macRequest(method, path, { body, raw, requestKey, ...options } = {}) {
30
+ const { url, token, agent, ca } = transportOptions(options);
31
+ if (!path.startsWith("/api/v1/mac/") || path.includes("#")) throw new CliError("Invalid Mac API path.");
32
+ const target = new URL(path, url);
33
+ if (target.origin !== url.origin) throw new CliError("Invalid Mac API origin.");
34
+ const payload = raw ?? (body === undefined ? undefined : Buffer.from(JSON.stringify(body)));
35
+ return new Promise((resolve, reject) => {
36
+ let settled = false;
37
+ const fail = (code, message, status) => {
38
+ if (settled) return;
39
+ settled = true;
40
+ reject(new CliError(message, undefined, status, code, requestKey ? { request_key: requestKey } : undefined));
41
+ };
42
+ const request = (target.protocol === "https:" ? https : http).request(target, {
43
+ method, agent, ca, headers: {
44
+ authorization: `Bearer ${token}`,
45
+ ...(payload ? { "content-type": raw ? "application/octet-stream" : "application/json", "content-length": payload.length } : {}),
46
+ ...(requestKey ? { "idempotency-key": requestKey } : {}),
47
+ },
48
+ }, response => {
49
+ const chunks = []; let bytes = 0;
50
+ response.on("data", chunk => {
51
+ bytes += chunk.length;
52
+ if (bytes > 768 * 1024) {
53
+ fail("response_too_large", "The gateway response exceeded its limit.");
54
+ response.destroy();
55
+ } else chunks.push(chunk);
56
+ });
57
+ response.on("error", () => fail("outcome_unknown", "Response lost. Inspect the job or retry with the same request key."));
58
+ response.on("end", () => {
59
+ if (settled) return;
60
+ let data;
61
+ try { data = JSON.parse(Buffer.concat(chunks).toString("utf8")); }
62
+ catch { return fail("bad_response", "The gateway returned an unreadable response. Reconcile before retrying.", response.statusCode); }
63
+ if (response.statusCode < 200 || response.statusCode >= 300) return fail(data.error?.code ?? "gateway_error", data.error?.message ?? "The Mac request failed.", response.statusCode);
64
+ settled = true;
65
+ resolve(data);
66
+ });
67
+ });
68
+ const deadline = setTimeout(() => request.destroy(new Error("deadline")), 15_000);
69
+ request.on("close", () => clearTimeout(deadline));
70
+ request.on("error", () => fail("outcome_unknown", "Connection lost. Inspect the job or retry with the same request key."));
71
+ request.end(payload);
72
+ });
73
+ }
74
+
75
+ /** A ProxyCommand: stdout contains only SSH bytes, never status or cost messages. */
76
+ export function tunnel(value, { input = process.stdin, output = process.stdout, ...options } = {}) {
77
+ const id = sessionId(value);
78
+ const { url, token, agent, ca } = transportOptions(options);
79
+ url.pathname = `/api/v1/mac/sessions/${id}/ssh`;
80
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
81
+ return new Promise((resolve, reject) => {
82
+ const ws = new WebSocket(url, {
83
+ agent, ca, headers: { authorization: `Bearer ${token}` },
84
+ handshakeTimeout: 15_000, perMessageDeflate: false, maxPayload: 1024 * 1024,
85
+ followRedirects: false,
86
+ });
87
+ const stream = createWebSocketStream(ws, { highWaterMark: 64 * 1024 });
88
+ let failure, closedCode, settled = false;
89
+ const finish = () => {
90
+ if (settled) return;
91
+ settled = true;
92
+ input.unpipe(stream); stream.unpipe(output); input.pause();
93
+ input.removeListener("error", fail); output.removeListener("error", fail);
94
+ if (failure || closedCode !== 1000) reject(failure ?? new CliError("SSH tunnel closed unexpectedly.", undefined, undefined, "tunnel_disconnected"));
95
+ else resolve();
96
+ };
97
+ const fail = () => { failure ??= new CliError("SSH tunnel disconnected. Reconnect explicitly; an existing SSH connection cannot be resumed.", undefined, undefined, "tunnel_disconnected"); stream.destroy(); finish(); };
98
+ stream.on("error", fail);
99
+ input.on("error", fail);
100
+ output.on("error", fail);
101
+ ws.on("unexpected-response", (_req, response) => {
102
+ failure = new CliError(`SSH tunnel refused (HTTP ${response.statusCode}).`, undefined, response.statusCode, "tunnel_refused");
103
+ response.resume();
104
+ ws.terminate();
105
+ });
106
+ ws.on("error", fail);
107
+ ws.on("close", code => {
108
+ closedCode = code;
109
+ input.unpipe(stream); input.pause();
110
+ if (code !== 1000 || failure) { stream.destroy(); finish(); }
111
+ });
112
+ // A close frame can arrive while the consumer is backpressured. Drain all
113
+ // buffered SSH bytes and the last output write before resolving.
114
+ stream.on("end", () => output.write(Buffer.alloc(0), error => { if (error) fail(); else finish(); }));
115
+ stream.pipe(output, { end: false });
116
+ input.pipe(stream);
117
+ });
118
+ }
package/src/mcp.mjs CHANGED
@@ -11,6 +11,7 @@ import { loadProjectState, saveProjectState } from "./config.mjs";
11
11
  import { packTarball } from "./pack.mjs";
12
12
  import { uploadSourceSnapshot } from "./upload.mjs";
13
13
  import { uploadStoreAsset } from "./store-upload.mjs";
14
+ import { MAC_TOOLS } from "./mac-tools.mjs";
14
15
 
15
16
  const PROTOCOL_FALLBACK = "2025-06-18";
16
17
 
@@ -27,6 +28,32 @@ async function resolveProjectId(args) {
27
28
  }
28
29
 
29
30
  export const TOOLS = [
31
+ ...MAC_TOOLS,
32
+ {
33
+ name: "check_project", description: "Free source-readiness check of the latest uploaded snapshot. No Apple credentials or paid compute. Run push_project first or after code changes; this does not prove compilation or TestFlight delivery.",
34
+ inputSchema: { type: "object", properties: { project_id: { type: "string" } }, additionalProperties: false },
35
+ handler: async args => text(await api("POST", "/api/v1/check-project", { body: { project_id: await resolveProjectId(args) } })),
36
+ },
37
+ {
38
+ name: "get_billing_status", description: "Read confirmed payment, contracted prices, quota/reset, renewal and first-purchase refund eligibility. After payment, resume a blocked build once with a fresh request_key only if the earlier response said build_started:false. Reconcile unknown outcomes first.",
39
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
40
+ handler: async () => text(await api("GET", "/api/v1/billing/status")),
41
+ },
42
+ {
43
+ name: "get_checkout_link", description: "Return an org-bound checkout link; the human chooses a plan and pays explicitly. This tool does not charge them. Reuses pending checkout; existing subscribers get billing management instead.",
44
+ inputSchema: { type: "object", properties: { plan: { type: "string", enum: ["starter", "pro"] } }, additionalProperties: false },
45
+ handler: async args => text(await api("POST", "/api/v1/billing/checkout", { body: args })),
46
+ },
47
+ {
48
+ name: "request_refund", description: "Preview the first-purchase refund amount and subscription cancellation. Default confirm:false. Present the quote and effect to the customer; only after explicit authorization for both actions call with confirm:true and its request_id. A build failure alone does not authorize a refund. Reason is optional.",
49
+ inputSchema: { type: "object", properties: { confirm: { type: "boolean", default: false }, request_id: { type: "string" }, reason: { type: "string", maxLength: 1000 } }, additionalProperties: false },
50
+ handler: async args => text(await api("POST", "/api/v1/billing/refund", { body: args })),
51
+ },
52
+ {
53
+ name: "get_refund_status", description: "Read the saved refund and cancellation progress. Does not submit a refund or cancel anything.",
54
+ inputSchema: { type: "object", properties: { request_id: { type: "string" } }, required: ["request_id"], additionalProperties: false },
55
+ handler: async args => text(await api("GET", `/api/v1/billing/refund?request_id=${encodeURIComponent(args.request_id)}`)),
56
+ },
30
57
  {
31
58
  name: "upload_store_asset",
32
59
  description: "Upload a new Apple screenshot, preview, purchase image, supplemental reviewer attachment or encryption document from file_path (up to 500 MiB, subject to Apple limits) or data_base64 (2 MiB). Discover the createInstance operation/body and parent scope with get_store_capabilities/read_store. Preview by default; confirm:true reserves, uploads and commits. Preserve request_key and identical bytes/body to resume. ready:true requires Apple processing COMPLETE. Message attachments require App Store Connect.",
@@ -114,6 +141,7 @@ export const TOOLS = [
114
141
  properties: {
115
142
  project_id: { type: "string" },
116
143
  workflow: { type: "string", enum: ["release", "smoke"], default: "release" },
144
+ request_key: { type: "string", description: "Reuse this key to reconcile an unknown response. Use a fresh key after a confirmed build_started:false and payment/setup completion." },
117
145
  },
118
146
  additionalProperties: false,
119
147
  },
@@ -121,7 +149,7 @@ export const TOOLS = [
121
149
  const project_id = await resolveProjectId(args);
122
150
  const res = await api("POST", "/api/v1/builds", {
123
151
  body: { project_id, workflow: args?.workflow ?? "release" },
124
- idempotencyKey: `mcp-${Date.now()}-${Math.random().toString(36).slice(2)}`,
152
+ idempotencyKey: args?.request_key ?? `mcp-${randomUUID()}`,
125
153
  });
126
154
  let warning;
127
155
  try {
@@ -400,7 +428,7 @@ export async function runMcpServer() {
400
428
  reply(await tool.handler(params?.arguments ?? {}));
401
429
  } catch (err) {
402
430
  reply({
403
- content: [{ type: "text", text: err?.friendly ?? err?.message ?? String(err) }],
431
+ content: [{ type: "text", text: err?.details ? JSON.stringify({ error: { ...err.details, message: err.friendly ?? err.message } }, null, 2) : err?.friendly ?? err?.message ?? String(err) }],
404
432
  isError: true,
405
433
  });
406
434
  }
@@ -0,0 +1,135 @@
1
+ import { spawn } from "node:child_process";
2
+ import { isAbsolute } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { CliError } from "./api.mjs";
5
+
6
+ export const shellQuote = (value) => `'${String(value).replaceAll("'", "'\\''")}'`;
7
+
8
+ export function validateTarget(target) {
9
+ // A hostname or user-owned SSH config alias, never command-line options or a shell expression.
10
+ if (typeof target !== "string" || !/^(?:[a-zA-Z0-9_][a-zA-Z0-9_.-]*@)?[a-zA-Z0-9_][a-zA-Z0-9_.-]*$/.test(target)) {
11
+ throw new CliError("invalid SSH target", "Use --host user@hostname or an alias from your SSH config. For IPv6, configure an SSH alias first.", undefined, "invalid_ssh_target");
12
+ }
13
+ return target;
14
+ }
15
+
16
+ export function validateWorkspace(path) {
17
+ if (typeof path !== "string" || !path.startsWith("/") || path === "/" || /[\x00-\x1f\x7f]/.test(path) || path.split("/").some(part => part === ".." || part === ".")) {
18
+ throw new CliError("invalid workspace", "The Mac returned an invalid workspace path.");
19
+ }
20
+ return path;
21
+ }
22
+
23
+ export function sshArgs(session, { batch = true } = {}) {
24
+ validateTarget(session.target);
25
+ if (session.port !== undefined && (!Number.isInteger(session.port) || session.port < 1 || session.port > 65535)) throw new CliError("invalid SSH port");
26
+ if (session.identity && (!isAbsolute(session.identity) || /[\x00-\x1f\x7f]/.test(session.identity))) throw new CliError("invalid identity path");
27
+ if (session.mode === "hosted") {
28
+ if (!/^ses_[A-Za-z0-9_-]{1,80}$/.test(session.id) || !isAbsolute(session.knownHosts ?? "") || /[\x00-\x1f\x7f]/.test(session.knownHosts)) throw new CliError("invalid hosted SSH state");
29
+ }
30
+ return [
31
+ ...(session.mode === "hosted" ? ["-F", "/dev/null", "-o", `UserKnownHostsFile=${session.knownHosts}`, "-o", "GlobalKnownHostsFile=/dev/null",
32
+ "-o", `HostKeyAlias=nomac-${session.id}`, "-o", `ProxyCommand=${[process.execPath, fileURLToPath(new URL("../bin/nomac.mjs", import.meta.url)), "tunnel", session.id].map(shellQuote).join(" ")}`] : []),
33
+ "-o", "StrictHostKeyChecking=yes", "-o", "ForwardAgent=no",
34
+ "-o", "ConnectTimeout=15", "-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=3",
35
+ // Avoid reusing a separately owned master connection with different authentication/forwarding.
36
+ "-o", "ControlMaster=no", "-o", "ControlPath=none",
37
+ ...(batch ? ["-o", "BatchMode=yes", "-o", "ClearAllForwardings=yes"] : []),
38
+ ...(session.port ? ["-p", String(session.port)] : []),
39
+ ...(session.identity ? ["-i", session.identity, "-o", "IdentitiesOnly=yes"] : []),
40
+ ];
41
+ }
42
+
43
+ export function runProcess(program, args, { input, inherit = false, maxBytes = 8 * 1024 * 1024, timeoutMs, env } = {}) {
44
+ return new Promise((resolve, reject) => {
45
+ const child = spawn(program, args, { shell: false, stdio: inherit ? "inherit" : ["pipe", "pipe", "pipe"], ...(env ? { env } : {}) });
46
+ const output = [], errors = [];
47
+ let bytes = 0, failure, timer;
48
+ const stop = (message) => { failure = new CliError(message); child.kill("SIGTERM"); };
49
+ if (timeoutMs) timer = setTimeout(() => stop("Remote operation timed out; inspect its outcome before retrying."), timeoutMs);
50
+ if (!inherit) {
51
+ for (const [stream, chunks] of [[child.stdout, output], [child.stderr, errors]]) {
52
+ stream.on("data", chunk => {
53
+ bytes += chunk.length;
54
+ if (bytes > maxBytes) stop("Command output exceeded the capture limit. Run the command through nomac ssh to stream its output.");
55
+ else chunks.push(chunk);
56
+ });
57
+ }
58
+ child.stdin.on("error", error => { if (error.code !== "EPIPE") failure = error; });
59
+ child.stdin.end(input);
60
+ }
61
+ child.on("error", error => {
62
+ clearTimeout(timer);
63
+ reject(new CliError(error.message, error.code === "ENOENT" ? `${program} is required but was not found in PATH.` : error.message, undefined, error.code === "ENOENT" ? "tool_missing" : "process_failed"));
64
+ });
65
+ child.on("close", (code, signal) => {
66
+ clearTimeout(timer);
67
+ if (failure) return reject(failure);
68
+ const result = { code: code ?? 1, signal, stdout: Buffer.concat(output).toString("utf8"), stderr: Buffer.concat(errors).toString("utf8") };
69
+ if (code !== 0) {
70
+ const error = new CliError(`${program} exited ${code ?? signal}`, result.stderr.trim() || `${program} exited ${code ?? signal}`, undefined, program === "ssh" ? "ssh_failed" : "transfer_failed");
71
+ error.exitCode = code && code > 0 ? code : 1;
72
+ error.result = result;
73
+ return reject(error);
74
+ }
75
+ resolve(result);
76
+ });
77
+ });
78
+ }
79
+
80
+ export function remote(session, script, { run = runProcess, ...options } = {}) {
81
+ return run("ssh", [...sshArgs(session), session.target, script], { timeoutMs: 60_000, ...options });
82
+ }
83
+
84
+ /** Does not install a project pipeline or run source code. Creates only this owned workspace. */
85
+ export async function prepareWorkspace(session, options) {
86
+ if (!/^nms_[a-f0-9]{32}$/.test(session.id)) throw new CliError("invalid session id");
87
+ const location = session.workspace
88
+ ? shellQuote(validateWorkspace(session.workspace))
89
+ : `"$HOME/.nomac/workspaces/${session.id}"`;
90
+ const script = [
91
+ 'test "$(uname -s)" = Darwin || { echo "Nomac needs a macOS host." >&2; exit 1; }',
92
+ "command -v rsync >/dev/null || { echo 'Install rsync on the Mac before using file transfer.' >&2; exit 1; }",
93
+ "perl -MJSON::PP -MDigest::SHA -MEncode -MFcntl -e 'exit 0' || { echo 'Perl checksum and JSON modules are required for file conflict checks.' >&2; exit 1; }",
94
+ `umask 077; mkdir -p ${location} && cd ${location} || exit 1`,
95
+ "pwd -P", "sw_vers -productVersion", "uname -m",
96
+ 'if [ "$(id -u)" = 0 ]; then echo admin; else id -Gn; fi', "df -Pk . | tail -1 | awk '{print $4}'",
97
+ ].join("\n");
98
+ const { stdout } = await remote(session, script, options);
99
+ const lines = stdout.trimEnd().split("\n");
100
+ if (lines.length !== 5) throw new CliError("unexpected Mac response", "The SSH shell printed unexpected output. Ensure non-interactive startup files are quiet, then retry nomac start.");
101
+ const [workspace, osVersion, architecture, groups, availableKb] = lines;
102
+ validateWorkspace(workspace);
103
+ if (!/^[0-9]+(?:\.[0-9]+)*$/.test(osVersion) || !/^[a-zA-Z0-9_]+$/.test(architecture) || !/^\d+$/.test(availableKb)) throw new CliError("invalid Mac capabilities");
104
+ const admin = groups.split(/\s+/).includes("admin");
105
+ if (!admin) throw new CliError("administrator account required", "This account is not a macOS administrator. Use an administrator account for a full Nomac workspace.");
106
+ return { workspace, capabilities: { os: "macOS", version: osVersion, architecture, administrator: admin, available_disk_bytes: Number(availableKb) * 1024 } };
107
+ }
108
+
109
+ export function workspaceCommand(session, args = []) {
110
+ const command = args.length ? args.map(shellQuote).join(" ") : '"$SHELL" -l';
111
+ return `cd ${shellQuote(validateWorkspace(session.workspace))} && exec ${command}`;
112
+ }
113
+
114
+ export function forwardingArg(value) {
115
+ const match = /^(\d{1,5}):(\d{1,5})$/.exec(value);
116
+ if (!match || match.slice(1).some(port => Number(port) < 1 || Number(port) > 65535)) throw new CliError("invalid port mapping", "Use --forward LOCAL_PORT:REMOTE_PORT, for example --forward 8080:3000.");
117
+ return `127.0.0.1:${Number(match[1])}:127.0.0.1:${Number(match[2])}`;
118
+ }
119
+
120
+ /** rsync has its own argument parser, not a POSIX shell: quoted apostrophes double. */
121
+ export const rsyncQuote = (value) => `'${String(value).replaceAll("'", "''")}'`;
122
+
123
+ export function rsyncServerCommand(session, argv, from = ".") {
124
+ const index = argv.findIndex((arg, i) => arg === "rsync" && argv[i + 1] === "--server");
125
+ if (index === -1) throw new CliError("invalid rsync server command");
126
+ const source = String(from);
127
+ if (source.startsWith("/") || source.split("/").includes("..") || /[\x00-\x1f\x7f\\]/.test(source)) throw new CliError("invalid transfer source");
128
+ return [
129
+ `cd ${shellQuote(validateWorkspace(session.workspace))} || exit 1`,
130
+ `p=${shellQuote(source === "." ? "." : `./${source}`)}`,
131
+ 'while [ "$p" != . ]; do test ! -L "$p" || exit 1; p=$(dirname "$p"); done',
132
+ `cd ${shellQuote(source)} || exit 1`,
133
+ `exec ${argv.slice(index).map(shellQuote).join(" ")}`,
134
+ ].join("\n");
135
+ }
@@ -0,0 +1,45 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { CliError } from "./api.mjs";
6
+
7
+ /** Session metadata stays outside the project: a checkout cannot choose an SSH target. */
8
+ export function sessionStore(projectDir = process.cwd(), stateDir = join(homedir(), ".nomac", "sessions")) {
9
+ const project = realpathSync(projectDir);
10
+ const name = createHash("sha256").update(project).digest("hex").slice(0, 24);
11
+ const directory = join(stateDir, name);
12
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
13
+ chmodSync(directory, 0o700);
14
+ const stateFile = join(directory, "session.json");
15
+ const baselineFile = join(directory, "baseline.json");
16
+ const read = (file) => {
17
+ if (!existsSync(file)) return null;
18
+ try { return JSON.parse(readFileSync(file, "utf8")); }
19
+ catch { throw new CliError("invalid session state", `Cannot read ${file}. Restore it before continuing; no remote operation was started.`); }
20
+ };
21
+ const write = (file, data) => {
22
+ const temporary = `${file}.${randomUUID()}.tmp`;
23
+ try {
24
+ writeFileSync(temporary, JSON.stringify(data, null, 2) + "\n", { mode: 0o600, flag: "wx" });
25
+ renameSync(temporary, file);
26
+ } finally { rmSync(temporary, { force: true }); }
27
+ };
28
+ return {
29
+ project, directory,
30
+ load: () => read(stateFile),
31
+ save: (value) => write(stateFile, value),
32
+ baseline: () => read(baselineFile) ?? {},
33
+ saveBaseline: (value) => write(baselineFile, value),
34
+ async locked(operation) {
35
+ const lock = join(directory, "operation.lock");
36
+ try { mkdirSync(lock, { mode: 0o700 }); }
37
+ catch (error) {
38
+ if (error.code !== "EEXIST") throw error;
39
+ throw new CliError("session busy", `Another session operation is running. If it was interrupted, verify it has ended before removing ${lock}.`);
40
+ }
41
+ try { return await operation(); }
42
+ finally { rmSync(lock, { recursive: true, force: true }); }
43
+ },
44
+ };
45
+ }
@@ -0,0 +1,215 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { constants, copyFileSync, createReadStream, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, chmodSync, utimesSync } from "node:fs";
3
+ import { dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import ignoreFactory from "ignore";
6
+ import { CliError } from "./api.mjs";
7
+ import { remote, rsyncQuote, runProcess, shellQuote, validateWorkspace } from "./session-ssh.mjs";
8
+
9
+ const EXCLUDED = [
10
+ ".git/", ".nomac/", ".nomac.json", ".nomac-transfer/", "node_modules/", "Pods/", "build/", "dist/", "DerivedData/", ".build/", ".next/",
11
+ ".env", ".env.*", "*.p8", "*.p12", "*.pem", "*.key", "*.mobileprovision", ".ssh/", ".aws/", ".netrc", ".npmrc", "id_rsa*", "id_ed25519*", ".DS_Store",
12
+ ];
13
+
14
+ export function projectFiles(root) {
15
+ const denied = ignoreFactory().add(EXCLUDED);
16
+ const files = [], skippedLinks = [];
17
+ const walk = (directory, inherited = []) => {
18
+ const scopes = [...inherited];
19
+ for (const name of [".gitignore", ".nomacignore"]) {
20
+ const path = join(directory, name);
21
+ // Ignore files themselves must not read through a symlink outside the project.
22
+ if (existsSync(path) && lstatSync(path).isFile()) scopes.push({ root: directory, rules: ignoreFactory().add(readFileSync(path, "utf8")) });
23
+ }
24
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
25
+ const path = join(directory, entry.name), rel = relative(root, path).split(sep).join("/");
26
+ const candidate = rel + (entry.isDirectory() ? "/" : "");
27
+ if (denied.ignores(candidate)) continue;
28
+ let ignored = false;
29
+ for (const scope of scopes) {
30
+ const result = scope.rules.test(relative(scope.root, path).split(sep).join("/") + (entry.isDirectory() ? "/" : ""));
31
+ if (result.ignored) ignored = true;
32
+ if (result.unignored) ignored = false;
33
+ }
34
+ if (ignored) continue;
35
+ if (entry.isSymbolicLink()) skippedLinks.push(rel);
36
+ else if (entry.isDirectory()) walk(path, scopes);
37
+ else if (entry.isFile()) files.push(rel);
38
+ }
39
+ };
40
+ walk(root);
41
+ return { files: files.sort(), skippedLinks: skippedLinks.sort() };
42
+ }
43
+
44
+ export async function fingerprint(path) {
45
+ const stat = lstatSync(path);
46
+ if (!stat.isFile()) return stat.isSymbolicLink() ? "symlink" : "other";
47
+ const hash = createHash("sha256");
48
+ for await (const chunk of createReadStream(path, { flags: constants.O_RDONLY | constants.O_NOFOLLOW })) hash.update(chunk);
49
+ return `file:${hash.digest("hex")}:${(stat.mode & 0o777).toString(8)}`;
50
+ }
51
+
52
+ export function safeRelative(path) {
53
+ if (typeof path !== "string" || isAbsolute(path) || path.split(/[\\/]/).includes("..") || /[\x00-\x1f\x7f]/.test(path) || path.includes("\\")) throw new CliError("invalid relative path", "Use a path within the workspace, without '..' or control characters.");
54
+ return posix.normalize(path || ".").replace(/\/$/, "") || ".";
55
+ }
56
+
57
+ // One process per batch, rather than launching shasum/stat for every source file.
58
+ // These Perl modules ship with the Perl used by macOS's checksum tools.
59
+ const REMOTE_FINGERPRINTS = `
60
+ use strict; use warnings;
61
+ use JSON::PP; use Digest::SHA; use Encode qw(encode_utf8);
62
+ use Fcntl qw(O_RDONLY O_NOFOLLOW);
63
+ my $json = JSON::PP->new->utf8;
64
+ my $names = $json->decode(do { local $/; <STDIN> });
65
+ die "Invalid file list" unless ref($names) eq 'ARRAY';
66
+ my %result;
67
+ for my $name (@$names) {
68
+ die "Invalid path" if ref($name) || $name =~ m{^/|(?:^|/)\\.\\.(?:/|$)|[\\x00-\\x1f\\x7f\\\\]};
69
+ my $file = './' . encode_utf8($name);
70
+ my @parts = split m{/}, $file;
71
+ my $unsafe = 0;
72
+ while (@parts > 1) {
73
+ if (-l join('/', @parts)) { $unsafe = 1; last; }
74
+ pop @parts;
75
+ }
76
+ if ($unsafe) { $result{$name} = 'symlink'; next; }
77
+ if (!-e $file) { $result{$name} = 'missing'; next; }
78
+ if (!-f $file) { $result{$name} = 'other'; next; }
79
+ sysopen(my $handle, $file, O_RDONLY | O_NOFOLLOW) or die "Cannot open source file";
80
+ binmode($handle);
81
+ my $mode = sprintf('%o', (stat($handle))[2] & 0777);
82
+ my $digest = Digest::SHA->new(256)->addfile($handle)->hexdigest;
83
+ close($handle);
84
+ $result{$name} = "file:$digest:$mode";
85
+ }
86
+ print $json->encode(\\%result);
87
+ `;
88
+
89
+ export async function remoteFingerprints(session, files, options) {
90
+ const result = Object.create(null);
91
+ for (let offset = 0; offset < files.length; offset += 1000) {
92
+ const batch = files.slice(offset, offset + 1000);
93
+ for (const path of batch) safeRelative(path);
94
+ const script = `cd ${shellQuote(validateWorkspace(session.workspace))} && perl -e ${shellQuote(REMOTE_FINGERPRINTS)}`;
95
+ const { stdout } = await remote(session, script, { ...options, input: JSON.stringify(batch) });
96
+ let values;
97
+ try { values = JSON.parse(stdout); } catch { throw new CliError("invalid remote file response"); }
98
+ if (!values || Array.isArray(values) || Object.keys(values).length !== batch.length) throw new CliError("invalid remote file response");
99
+ for (const path of batch) {
100
+ const value = Object.hasOwn(values, path) && values[path];
101
+ if (typeof value !== "string" || !/^(?:missing|symlink|other|file:[a-f0-9]{64}:[0-7]{1,4})$/.test(value)) throw new CliError("invalid remote fingerprint");
102
+ Object.defineProperty(result, path, { value, enumerable: true, configurable: true, writable: true });
103
+ }
104
+ }
105
+ return result;
106
+ }
107
+
108
+ function transferArgs() {
109
+ const bridge = fileURLToPath(new URL("../bin/rsync-ssh.mjs", import.meta.url));
110
+ return ["-rpt", "--checksum", "--itemize-changes", "--stats", "--timeout=60", "-e", [process.execPath, bridge].map(rsyncQuote).join(" ")];
111
+ }
112
+
113
+ const transferEnv = (session, remoteDirectory = ".") => ({ ...process.env, NOMAC_TRANSFER_SESSION: JSON.stringify({ ...session, remoteDirectory }) });
114
+
115
+ function conflictError(paths, side) {
116
+ const error = new CliError("transfer conflict", `${side} changes would be overwritten: ${paths.slice(0, 10).join(", ")}. Retrieve/reconcile the changes first, or explicitly use --overwrite.`);
117
+ error.code = "transfer_conflict";
118
+ error.details = { paths, side };
119
+ return error;
120
+ }
121
+
122
+ export async function syncProject(session, store, { overwrite = false, dryRun = false, run = runProcess } = {}) {
123
+ const { files, skippedLinks } = projectFiles(store.project);
124
+ if (!files.length) throw new CliError("no project files", "No files remain after .gitignore, .nomacignore and default exclusions.");
125
+ const local = Object.create(null);
126
+ for (const path of files) local[path] = await fingerprint(join(store.project, path));
127
+ const baseline = Object.assign(Object.create(null), store.baseline());
128
+ const previous = await remoteFingerprints(session, files, { run });
129
+ const unsafe = files.filter(path => ["symlink", "other"].includes(previous[path]));
130
+ if (unsafe.length) throw new CliError("unsafe remote path", `Remote paths are symlinks or have incompatible types: ${unsafe.slice(0, 10).join(", ")}. Resolve them inside the workspace first.`);
131
+ const conflicts = files.filter(path => previous[path] !== "missing" && previous[path] !== local[path] && previous[path] !== baseline[path]);
132
+ if (conflicts.length && !overwrite) throw conflictError(conflicts, "Remote");
133
+ if (dryRun) return { dry_run: true, files: files.length, changed: files.filter(path => previous[path] !== local[path]), skipped_symlinks: skippedLinks };
134
+ const output = await run("rsync", [...transferArgs(), "--from0", "--files-from=-", `${store.project}/`, `${session.target}:./`], { input: Buffer.from(files.join("\0") + "\0"), env: transferEnv(session) });
135
+ const after = await remoteFingerprints(session, files, { run });
136
+ const changedDuringTransfer = files.filter(path => after[path] !== local[path]);
137
+ for (const path of files) if (after[path] === local[path]) baseline[path] = local[path];
138
+ store.saveBaseline(baseline);
139
+ if (changedDuringTransfer.length) throw new CliError("transfer changed during verification", "Some files changed during transfer. Stop concurrent edits and rerun sync; verified files were recorded.");
140
+ return { files: files.length, changed: files.filter(path => previous[path] !== local[path]), skipped_symlinks: skippedLinks, output: output.stdout };
141
+ }
142
+
143
+ /** Refuse symlink ancestors, including the destination itself, before local writes. */
144
+ export function assertSafeDestination(root, target) {
145
+ const rel = relative(root, target);
146
+ if (rel.startsWith(`..${sep}`) || rel === ".." || isAbsolute(rel)) throw new CliError("destination escapes project");
147
+ let current = root;
148
+ for (const part of rel.split(sep).filter(Boolean)) {
149
+ current = join(current, part);
150
+ try { if (lstatSync(current).isSymbolicLink()) throw new CliError("unsafe local path", `Refusing to write through symlink: ${current}`); }
151
+ catch (error) { if (error.code !== "ENOENT") throw error; }
152
+ }
153
+ }
154
+
155
+ export async function pullProject(session, store, destination, { from = destination, overwrite = false, dryRun = false, run = runProcess } = {}) {
156
+ destination = safeRelative(destination); from = safeRelative(from);
157
+ const localRoot = resolve(store.project, destination);
158
+ assertSafeDestination(store.project, localRoot);
159
+ const staging = mkdtempSync(join(store.directory, "pull-"));
160
+ const backup = join(store.directory, "backups", randomUUID());
161
+ try {
162
+ // Download into an empty private directory; rsync never receives the user's working tree as destination.
163
+ await run("rsync", [...transferArgs(), `${session.target}:./`, `${staging}/`], { env: transferEnv(session, from) });
164
+ const files = [];
165
+ const walk = (directory) => {
166
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
167
+ const path = join(directory, entry.name);
168
+ if (entry.isDirectory()) walk(path);
169
+ else if (entry.isFile()) files.push(relative(staging, path));
170
+ else throw new CliError("unsupported download entry", "The download contains a symlink or special file. Retrieve it explicitly over SSH after reviewing it.");
171
+ }
172
+ };
173
+ walk(staging);
174
+ const baseline = Object.assign(Object.create(null), store.baseline()), plan = [], conflicts = [];
175
+ for (const path of files) {
176
+ const target = join(localRoot, path), incoming = await fingerprint(join(staging, path));
177
+ assertSafeDestination(store.project, target);
178
+ const current = existsSync(target) ? await fingerprint(target) : "missing";
179
+ if (current === "other") throw new CliError("incompatible local path", `A directory or special file occupies ${target}.`);
180
+ const remotePath = from === "." ? path : `${from.replace(/\/$/, "")}/${path}`;
181
+ // A baseline is meaningful only when downloading to the original project-relative path.
182
+ const samePath = relative(store.project, target).split(sep).join("/") === remotePath.split(sep).join("/");
183
+ if (current !== "missing" && current !== incoming && (!samePath || baseline[remotePath] !== current)) conflicts.push(path);
184
+ plan.push({ path, target, current, incoming, remotePath, samePath });
185
+ }
186
+ if (conflicts.length && !overwrite) throw conflictError(conflicts, "Local");
187
+ if (dryRun) return { dry_run: true, files: files.length, changed: plan.filter(file => file.current !== file.incoming).map(file => file.path) };
188
+ let backups = false;
189
+ for (const file of plan) {
190
+ assertSafeDestination(store.project, file.target);
191
+ const current = existsSync(file.target) ? await fingerprint(file.target) : "missing";
192
+ if (current !== file.current) throw new CliError("local file changed", "A local file changed during pull. Stop concurrent edits and retry. Earlier replacements have recovery copies.");
193
+ if (current !== file.incoming) {
194
+ mkdirSync(dirname(file.target), { recursive: true });
195
+ if (current !== "missing") {
196
+ const recovery = join(backup, file.path);
197
+ mkdirSync(dirname(recovery), { recursive: true, mode: 0o700 });
198
+ copyFileSync(file.target, recovery, constants.COPYFILE_EXCL);
199
+ backups = true;
200
+ }
201
+ const temporary = join(dirname(file.target), `.nomac-download-${randomUUID()}`);
202
+ try {
203
+ const sourcePath = join(staging, file.path), stat = lstatSync(sourcePath);
204
+ copyFileSync(sourcePath, temporary, constants.COPYFILE_EXCL);
205
+ chmodSync(temporary, stat.mode & 0o777);
206
+ utimesSync(temporary, stat.atime, stat.mtime);
207
+ renameSync(temporary, file.target);
208
+ } finally { rmSync(temporary, { force: true }); }
209
+ }
210
+ if (file.samePath) baseline[file.remotePath] = file.incoming;
211
+ }
212
+ store.saveBaseline(baseline);
213
+ return { files: files.length, changed: plan.filter(file => file.current !== file.incoming).map(file => file.path), ...(backups ? { recovery_directory: backup } : {}) };
214
+ } finally { rmSync(staging, { recursive: true, force: true }); }
215
+ }
@@ -0,0 +1,144 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync, realpathSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { parseArgs } from "node:util";
5
+ import { CliError } from "./api.mjs";
6
+ import { sessionStore } from "./session-store.mjs";
7
+ import { forwardingArg, prepareWorkspace, runProcess, shellQuote, sshArgs, validateTarget, workspaceCommand } from "./session-ssh.mjs";
8
+ import { pullProject, syncProject } from "./session-transfer.mjs";
9
+
10
+ export const SESSION_HELP = `Nomac 2.0 — SSH workspace preview
11
+
12
+ First connection (use a dedicated Mac you control):
13
+ nomac start --host dev@my-mac [--identity ~/.ssh/id_ed25519] [--port 22]
14
+
15
+ Then, from the same local project:
16
+ nomac start reconnect to the same Mac workspace
17
+ nomac sync . upload changed project files
18
+ nomac sync --dry-run inspect changes before uploading
19
+ nomac ssh open a normal shell in the workspace
20
+ nomac ssh -- node --version run any command (quote shell scripts explicitly)
21
+ nomac ssh --forward 8080:3000
22
+ nomac pull ./output download a remote directory into ./output
23
+ nomac pull . bring remote source changes back, with conflict checks
24
+ nomac session status --json inspect connection, capabilities and ownership
25
+ nomac stop detach locally; the externally owned Mac stays running
26
+
27
+ Options:
28
+ sync/pull --overwrite explicitly replace conflicting files
29
+ pull --from PATH choose a different remote directory
30
+ start/sync/pull/stop --json return machine-readable results
31
+
32
+ Requires OpenSSH and rsync locally; macOS, rsync and Perl checksum/JSON modules remotely.
33
+ SSH host keys must already be verified in your known_hosts. Your SSH config/agent
34
+ can authenticate; private keys never leave this device. Passwordless sudo is not
35
+ assumed. Use .nomacignore and .gitignore for project exclusions. Symlinks are
36
+ omitted in this preview; use Git/SSH explicitly for projects that need them.
37
+
38
+ This preview connects an existing Mac. It does not rent hardware, meter charges,
39
+ revoke the owner's SSH access or shut down the Mac. Stop detaches this CLI only;
40
+ existing connections and remote processes continue. Manage rentals with their owner.`;
41
+
42
+ const commonOptions = { help: { type: "boolean" }, json: { type: "boolean" } };
43
+ const commandOptions = {
44
+ start: { host: { type: "string" }, identity: { type: "string" }, port: { type: "string" } },
45
+ sync: { overwrite: { type: "boolean" }, "dry-run": { type: "boolean" } },
46
+ pull: { overwrite: { type: "boolean" }, "dry-run": { type: "boolean" }, from: { type: "string" } },
47
+ ssh: { forward: { type: "string", multiple: true } },
48
+ stop: {}, session: {},
49
+ };
50
+
51
+ export function describeSession(session) {
52
+ return {
53
+ id: session.id, mode: "external_ssh", state: session.state,
54
+ connection: { target: session.target, port: session.port ?? null, ...(session.identity ? { identity_file: session.identity } : {}) },
55
+ workspace: session.workspace ?? null, capabilities: session.capabilities ?? null,
56
+ ssh_command: session.workspace ? ["ssh", ...sshArgs(session, { batch: false }), session.target, workspaceCommand(session)].map(shellQuote).join(" ") : null,
57
+ checked_at: session.checkedAt ?? null,
58
+ billing: { managed_by: "mac_owner", nomac_compute_charges: false, rental_end_confirmed: false },
59
+ retention: "The Mac owner controls retention. Disconnecting or detaching does not remove the workspace.",
60
+ access_revoked: false, machine_stopped: false,
61
+ };
62
+ }
63
+
64
+ function requireReady(store) {
65
+ const session = store.load();
66
+ if (!session || session.state !== "ready") throw new CliError("no active workspace", "Run nomac start first. For a new project, use nomac start --host user@mac.", undefined, "no_active_session");
67
+ if (session.mode !== "external_ssh") throw new CliError("unsupported session mode");
68
+ return session;
69
+ }
70
+
71
+ export async function sessionCommand(command, argv, { store: suppliedStore, run = runProcess, print = console.log } = {}) {
72
+ let parsed;
73
+ try { parsed = parseArgs({ args: argv, options: { ...commonOptions, ...commandOptions[command] }, allowPositionals: true }); }
74
+ catch (error) { throw new CliError("invalid session options", `${error.message}\nRun nomac ${command} --help.`); }
75
+ const { values, positionals } = parsed;
76
+ if (values.help) return print(SESSION_HELP);
77
+ const store = suppliedStore ?? sessionStore();
78
+ const output = (result, summary) => print(values.json ? JSON.stringify(result, null, 2) : summary);
79
+
80
+ if (command === "session") {
81
+ if (positionals.length !== 1 || !["status", "info"].includes(positionals[0])) throw new CliError("session usage", "Usage: nomac session status [--json]");
82
+ const session = store.load();
83
+ if (!session) throw new CliError("no workspace", "Run nomac start --host user@mac first.");
84
+ const result = describeSession(session);
85
+ return output(result, `${session.state}: ${session.target}\nWorkspace: ${session.workspace ?? "not connected"}\nLast checked: ${session.checkedAt ?? "never"}\nThis is cached status. Run nomac start to check the Mac.\nThe Mac owner controls charges and retention.`);
86
+ }
87
+
88
+ if (command === "ssh") {
89
+ if (values.json) throw new CliError("SSH is a byte stream", "Use nomac session status --json for connection metadata; SSH streams the remote command directly.");
90
+ const session = requireReady(store);
91
+ const forward = (values.forward ?? []).flatMap(value => ["-L", forwardingArg(value)]);
92
+ const terminal = !positionals.length && process.stdin.isTTY && process.stdout.isTTY;
93
+ return run("ssh", [...sshArgs(session, { batch: false }), ...(terminal ? ["-t"] : []), ...forward,
94
+ "-o", "ExitOnForwardFailure=yes", session.target, workspaceCommand(session, positionals)], { inherit: true });
95
+ }
96
+
97
+ return store.locked(async () => {
98
+ if (command === "start") {
99
+ if (positionals.length) throw new CliError("start usage", "Usage: nomac start [--host user@mac] [--identity PATH] [--port PORT]");
100
+ let session = store.load();
101
+ if (values.host && session?.state === "ready" && session.target !== values.host) throw new CliError("workspace already active", "Run nomac stop before selecting another Mac. Your existing remote files will remain on that Mac.");
102
+ const target = validateTarget(values.host ?? session?.target ?? "");
103
+ if (!session || session.target !== target) {
104
+ session = { id: `nms_${randomUUID().replaceAll("-", "")}`, mode: "external_ssh", state: "connecting", target, createdAt: new Date().toISOString() };
105
+ store.saveBaseline({});
106
+ }
107
+ if (values.port) {
108
+ if (!/^\d+$/.test(values.port) || Number(values.port) < 1 || Number(values.port) > 65535) throw new CliError("invalid SSH port");
109
+ session.port = Number(values.port);
110
+ }
111
+ if (values.identity) {
112
+ const identity = resolve(values.identity);
113
+ if (!existsSync(identity)) throw new CliError("identity missing", `SSH identity file does not exist: ${identity}`);
114
+ session.identity = realpathSync(identity);
115
+ }
116
+ session.state = "connecting";
117
+ store.save(session); // Retain the same workspace ID if the connection response is lost.
118
+ const prepared = await prepareWorkspace(session, { run });
119
+ Object.assign(session, prepared, { state: "ready", checkedAt: new Date().toISOString() });
120
+ store.save(session);
121
+ return output(describeSession(session), `Connected to macOS ${prepared.capabilities.version} on ${target}\nWorkspace: ${prepared.workspace}\nNext: nomac sync . → nomac ssh\nExisting-Mac preview: rental and retention remain managed by the Mac owner.`);
122
+ }
123
+ if (command === "stop") {
124
+ if (positionals.length) throw new CliError("stop usage", "Usage: nomac stop [--json]");
125
+ const session = store.load();
126
+ if (!session) return output({ state: "detached", machine_stopped: false, access_revoked: false }, "No workspace is attached.");
127
+ session.state = "detached";
128
+ store.save(session);
129
+ return output(describeSession(session), "Detached locally. Existing SSH connections, remote processes and files remain.\nThis does not shut down the Mac, revoke owner access or end a rental.\nRun nomac start to reconnect to the same workspace.");
130
+ }
131
+ const session = requireReady(store);
132
+ if (command === "sync") {
133
+ if (positionals.length > 1 || (positionals[0] && realpathSync(resolve(positionals[0])) !== store.project)) throw new CliError("sync project mismatch", "Run nomac sync . from the project used by nomac start.");
134
+ const result = await syncProject(session, store, { overwrite: values.overwrite, dryRun: values["dry-run"], run });
135
+ return output(result, `${result.dry_run ? "Would sync" : "Synced"} ${result.changed.length} changed files (${result.files} considered).${result.skipped_symlinks.length ? `\nSkipped symlinks: ${result.skipped_symlinks.join(", ")}` : ""}`);
136
+ }
137
+ if (command === "pull") {
138
+ if (positionals.length !== 1) throw new CliError("pull usage", "Usage: nomac pull LOCAL_DIRECTORY [--from REMOTE_DIRECTORY] [--dry-run] [--overwrite]");
139
+ const result = await pullProject(session, store, positionals[0], { from: values.from ?? positionals[0], overwrite: values.overwrite, dryRun: values["dry-run"], run });
140
+ return output(result, `${result.dry_run ? "Would retrieve" : "Retrieved"} ${result.changed.length} changed files (${result.files} considered).${result.recovery_directory ? `\nPrevious local files: ${result.recovery_directory}` : ""}`);
141
+ }
142
+ throw new CliError("unknown session command");
143
+ });
144
+ }
@@ -0,0 +1,91 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { readFileSync, statSync, writeFileSync, linkSync, unlinkSync } from "node:fs";
3
+ import { resolve, dirname, join } from "node:path";
4
+ import { CliError } from "./api.mjs";
5
+ import { macRequest, sessionId } from "./mac-transport.mjs";
6
+
7
+ const HELP = `Nomac 2.0 transport probe (an operator-configured SSH target; no allocation or billing)
8
+
9
+ Set NOMAC_GATEWAY_URL and NOMAC_SESSION_TOKEN outside the project.
10
+ nomac tunnel <session> SSH ProxyCommand byte stream
11
+ nomac transport status <session> connection capabilities
12
+ nomac transport exec <session> --request-key KEY [--timeout SECONDS] -- command args...
13
+ nomac transport job <session> <job> inspect the existing job
14
+ nomac transport output <session> <job> [--cursor N] [--stream stdout|stderr]
15
+ nomac transport put <session> <file> upload a checksummed probe blob (up to 8 MiB)
16
+ nomac transport get <session> <sha256> <file> download a probe blob without overwriting
17
+
18
+ Results are JSON. Output chunks are base64 with byte cursors, so binary output is lossless.
19
+ Reuse the same request key after a lost response. Commands are never automatically rerun.
20
+ Probe blobs do not yet implement project sync; ordinary SSH/rsync remains available.`;
21
+
22
+ function parsed(args, allowed) {
23
+ const values = {};
24
+ for (let index = 0; index < args.length; index++) {
25
+ const flag = args[index];
26
+ if (flag === "--json") continue;
27
+ if (!allowed.includes(flag) || index + 1 >= args.length || values[flag] !== undefined) throw new CliError(`Unexpected transport option: ${flag}`);
28
+ values[flag] = args[++index];
29
+ }
30
+ return values;
31
+ }
32
+
33
+ export async function transportCommand(args, { request = macRequest, print = console.log } = {}) {
34
+ if (!args.length || ["help", "--help"].includes(args[0])) return print(HELP);
35
+ const [command, value, ...rest] = args;
36
+ const id = sessionId(value);
37
+ const base = `/api/v1/mac/sessions/${id}`;
38
+ let result;
39
+ if (command === "status") {
40
+ parsed(rest, []); result = await request("GET", base);
41
+ } else if (command === "exec") {
42
+ const separator = rest.indexOf("--");
43
+ if (separator === -1 || separator === rest.length - 1) throw new CliError("Use -- before the command and its arguments.");
44
+ const options = parsed(rest.slice(0, separator), ["--request-key", "--timeout"]);
45
+ if (!/^[A-Za-z0-9_.:-]{8,128}$/.test(options["--request-key"] ?? "")) throw new CliError("Supply a stable --request-key of 8 to 128 safe characters.");
46
+ const timeout = Number(options["--timeout"] ?? 60);
47
+ if (!Number.isInteger(timeout) || timeout < 1 || timeout > 600) throw new CliError("--timeout must be 1 to 600 seconds.");
48
+ result = await request("POST", `${base}/jobs`, { requestKey: options["--request-key"], body: { argv: rest.slice(separator + 1), timeout_seconds: timeout } });
49
+ } else if (command === "job" || command === "output") {
50
+ const [job, ...flags] = rest;
51
+ if (!/^job_[a-f0-9]{40}$/.test(job ?? "")) throw new CliError("Invalid job ID.");
52
+ const options = parsed(flags, command === "output" ? ["--cursor", "--stream"] : []);
53
+ const cursor = options["--cursor"] ?? "0", stream = options["--stream"] ?? "stdout";
54
+ if (!/^\d{1,16}$/.test(cursor) || !Number.isSafeInteger(Number(cursor)) || !["stdout", "stderr"].includes(stream)) throw new CliError("Invalid output stream or cursor.");
55
+ result = await request("GET", `${base}/jobs/${job}${command === "output" ? `/output?cursor=${cursor}&stream=${stream}` : ""}`);
56
+ } else if (command === "put") {
57
+ const [file, ...flags] = rest; parsed(flags, []);
58
+ if (!file) throw new CliError("Supply the file to upload.");
59
+ const info = statSync(file);
60
+ if (!info.isFile() || info.size > 8 * 1024 * 1024) throw new CliError("The probe accepts regular files up to 8 MiB.");
61
+ const data = readFileSync(file);
62
+ if (data.length > 8 * 1024 * 1024) throw new CliError("The file exceeded the probe limit.");
63
+ const digest = createHash("sha256").update(data).digest("hex");
64
+ let offset = 0;
65
+ do {
66
+ result = await request("PUT", `${base}/blobs/${digest}?size=${data.length}&offset=${offset}`, { raw: data.subarray(offset, offset + 256 * 1024) });
67
+ offset += 256 * 1024;
68
+ } while (offset < data.length && !result.complete);
69
+ if (!result.complete || result.id !== digest) throw new CliError("The upload was not confirmed. Retry the same file to resume.", undefined, undefined, "transfer_unconfirmed");
70
+ } else if (command === "get") {
71
+ const [digest, file, ...flags] = rest; parsed(flags, []);
72
+ if (!/^[a-f0-9]{64}$/.test(digest ?? "") || !file) throw new CliError("Supply the blob SHA-256 and destination file.");
73
+ const chunks = []; let cursor = 0, size;
74
+ for (;;) {
75
+ const chunk = await request("GET", `${base}/blobs/${digest}?cursor=${cursor}`);
76
+ const data = Buffer.from(chunk.data_base64 ?? "", "base64");
77
+ size ??= chunk.size;
78
+ if (chunk.id !== digest || !Number.isSafeInteger(size) || size < 0 || size > 8 * 1024 * 1024 || chunk.size !== size || chunk.cursor !== cursor || chunk.next_cursor !== cursor + data.length || chunk.next_cursor > size || (!data.length && !chunk.eof)) throw new CliError("Invalid file transfer response.", undefined, undefined, "transfer_unconfirmed");
79
+ chunks.push(data); cursor = chunk.next_cursor;
80
+ if (chunk.eof) break;
81
+ }
82
+ const data = Buffer.concat(chunks);
83
+ if (data.length !== size || createHash("sha256").update(data).digest("hex") !== digest) throw new CliError("Download checksum mismatch. No destination file was written.", undefined, undefined, "checksum_mismatch");
84
+ const destination = resolve(file), temporary = join(dirname(destination), `.nomac-download-${randomBytes(12).toString("hex")}`);
85
+ writeFileSync(temporary, data, { flag: "wx", mode: 0o600 });
86
+ try { linkSync(temporary, destination); } finally { unlinkSync(temporary); }
87
+ result = { id: digest, size: data.length, file: destination, complete: true };
88
+ } else throw new CliError(`Unknown transport command: ${command}`);
89
+ print(JSON.stringify(result));
90
+ return result;
91
+ }
package/src/upload.mjs CHANGED
@@ -9,7 +9,7 @@ export async function uploadSourceSnapshot(projectId, tarball) {
9
9
  } catch (error) {
10
10
  // Older servers have no grant route. Outages and authorization failures
11
11
  // should remain actionable, not silently retry through another transport.
12
- if (!(error instanceof CliError) || error.status !== 404 || error.code) throw error;
12
+ if (!(error instanceof CliError) || error.status !== 404 || (error.code && error.code !== "bad_response")) throw error;
13
13
  if (tarball.length > 4 * 1024 * 1024) {
14
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
15
  }