@nomac/cli 0.2.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/nomac.mjs +4 -1
- package/bin/rsync-ssh.mjs +13 -0
- package/package.json +6 -3
- package/skill/SKILL.md +36 -7
- package/src/api.mjs +10 -4
- package/src/cli.mjs +57 -2
- package/src/hosted-sessions.mjs +191 -0
- package/src/mac-credits.mjs +10 -0
- package/src/mac-tools.mjs +18 -0
- package/src/mac-transport.mjs +118 -0
- package/src/mcp.mjs +33 -3
- package/src/session-ssh.mjs +135 -0
- package/src/session-store.mjs +45 -0
- package/src/session-transfer.mjs +215 -0
- package/src/sessions.mjs +144 -0
- package/src/transport-command.mjs +91 -0
- package/src/upload.mjs +1 -1
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
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.3.0",
|
|
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
|
@@ -1,15 +1,44 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: nomac
|
|
3
3
|
description: >
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
any directory containing an Xcode project (SwiftUI vibe-code exports
|
|
9
|
-
included).
|
|
4
|
+
Use a full cloud Mac over SSH, HTTPS or MCP for macOS tools, Xcode and iOS
|
|
5
|
+
development. Start sessions, sync files, run commands, retrieve work and
|
|
6
|
+
stop. Also supports the separate iOS Builder product for TestFlight and
|
|
7
|
+
App Store publishing, signing, screenshots, metadata and review replies.
|
|
10
8
|
---
|
|
11
9
|
|
|
12
|
-
# nomac —
|
|
10
|
+
# nomac — a Mac for your agent
|
|
11
|
+
|
|
12
|
+
NoMac 2.0 provides a full macOS VM with administrator access. You choose the
|
|
13
|
+
commands and tools. A weekly Mac subscription is separate from Builder.
|
|
14
|
+
|
|
15
|
+
## Cloud Mac setup and workflow
|
|
16
|
+
|
|
17
|
+
1. Sign in at https://nomac.app/mac. The human chooses $5/week for 5 hours
|
|
18
|
+
or $20/week for 25 hours. Verify with `get_mac_credits`; a checkout URL
|
|
19
|
+
is not a payment. No Apple Developer account is needed for an unsigned build.
|
|
20
|
+
2. Install `npm install -g @nomac/cli`, run `nomac login`, and give the human
|
|
21
|
+
the printed device URL and code. MCP: `claude mcp add nomac -- npx @nomac/cli mcp`.
|
|
22
|
+
Hosted MCP: https://mcp.nomac.app/mcp with `Authorization: Bearer <nmk_key>`.
|
|
23
|
+
3. `nomac start --json`, wait until ready, `nomac sync .`, then `nomac ssh`.
|
|
24
|
+
For managed commands: `nomac exec --request-key stable-key-0001 -- COMMAND`,
|
|
25
|
+
then `nomac job JOB_ID` and `nomac output JOB_ID`. MCP equivalents are
|
|
26
|
+
`start_mac`, `exec_mac`, `get_mac_job`, `read_mac_output`, `stop_mac`.
|
|
27
|
+
4. Download work with `nomac pull ./build` (use `--from PATH` for another
|
|
28
|
+
remote directory), then `nomac stop --json`. Confirm `cleanup_confirmed`.
|
|
29
|
+
|
|
30
|
+
Stopping deletes files and logs. Download before stopping. SSH disconnects
|
|
31
|
+
do not stop the session. Ready time includes idle time; queue/setup are free.
|
|
32
|
+
Unused hours expire weekly. One session at a time; no automatic overage.
|
|
33
|
+
Keep the same request key and arguments after uncertain responses; inspect
|
|
34
|
+
the original job before retrying. Output retrieval failure is not job failure.
|
|
35
|
+
Managed jobs have a 600-second limit; SSH can run longer within session limits.
|
|
36
|
+
CLI sync skips symlinks and preserves remote extra files; use native SSH/rsync
|
|
37
|
+
when needed. GUI/simulator control and persistent disks are not currently offered.
|
|
38
|
+
Report failures using `report_issue` with `session_id`, optional `job_id` and
|
|
39
|
+
a description without secrets. Read https://nomac.app/llms.txt for full limits.
|
|
40
|
+
|
|
41
|
+
## Separate Builder product
|
|
13
42
|
|
|
14
43
|
nomac is the hands; you are the brain. It builds, signs, uploads, lints for
|
|
15
44
|
review-readiness, writes store metadata, manages pricing/products and submits for review —
|
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
|
|
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})
|
|
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 —
|
|
7
|
+
const HELP = `nomac — Mac workspaces for agents / iOS app publishing
|
|
8
8
|
|
|
9
|
-
|
|
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,191 @@
|
|
|
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
|
+
const validRequestKey = value => typeof value === "string" && /^[A-Za-z0-9_.:-]{8,128}$/.test(value);
|
|
49
|
+
// Only explicit pre-allocation rejections permit a fresh intent. A timeout,
|
|
50
|
+
// unreadable response or idempotency conflict may still own a real session.
|
|
51
|
+
const startRejections = new Set(["invalid_request", "invalid_public_key", "profile_unavailable", "session_cap_too_low",
|
|
52
|
+
"weekly_time_unavailable", "weekly_time_expired", "insufficient_compute_credit", "compute_account_blocked",
|
|
53
|
+
"session_limit_reached", "organization_not_found", "compute_unavailable", "unauthenticated", "invalid_origin"]);
|
|
54
|
+
export async function sessionAccess(id, request = api) {
|
|
55
|
+
sessionId(id);
|
|
56
|
+
const access = await request("POST", `/api/v1/mac/sessions/${id}/access`);
|
|
57
|
+
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.");
|
|
58
|
+
// Validates the gateway origin and token without saving bearer credentials to disk.
|
|
59
|
+
transportOptions({ gateway: access.gateway, token: access.token });
|
|
60
|
+
return access;
|
|
61
|
+
}
|
|
62
|
+
export async function hostedRequest(id, method, path, params = {}, { request = api, transport = macRequest } = {}) {
|
|
63
|
+
const access = await sessionAccess(id, request);
|
|
64
|
+
return transport(method, path, { ...params, gateway: access.gateway, token: access.token });
|
|
65
|
+
}
|
|
66
|
+
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 } = {}) {
|
|
67
|
+
let parsed;
|
|
68
|
+
try { parsed = parseArgs({ args: argv, options, allowPositionals: true }); }
|
|
69
|
+
catch (error) { throw new CliError(error.message); }
|
|
70
|
+
const { values, positionals } = parsed;
|
|
71
|
+
if (values.help) return print(HELP);
|
|
72
|
+
const wait = ["start", "stop"].includes(command) ? integer(values.wait, command === "start" ? 300 : 60, 0, command === "start" ? 600 : 300) : 0;
|
|
73
|
+
if (command === "start" && values["request-key"] !== undefined && !validRequestKey(values["request-key"])) throw new CliError("Use a request key of 8–128 letters, digits, underscores, periods, colons or hyphens.");
|
|
74
|
+
const output = result => {
|
|
75
|
+
const billing = result.session?.billing;
|
|
76
|
+
const usage = billing?.mode === "weekly_included"
|
|
77
|
+
? `Included time used: ${billing.used_seconds} seconds. No extra charge.`
|
|
78
|
+
: `Cost: ${JSON.stringify(result.session?.cost)}`;
|
|
79
|
+
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}`);
|
|
80
|
+
return result;
|
|
81
|
+
};
|
|
82
|
+
const saveView = (local, view) => {
|
|
83
|
+
if (view.id !== local.id && local.id) throw new CliError("Session response mismatch.");
|
|
84
|
+
sessionId(view.id);
|
|
85
|
+
Object.assign(local, { id: view.id, state: view.state, snapshot: view }); store.save(local);
|
|
86
|
+
return view;
|
|
87
|
+
};
|
|
88
|
+
const refresh = async local => saveView(local, (await request("GET", `/api/v1/mac/sessions/${sessionId(local.id)}`)).session);
|
|
89
|
+
async function ready(local) {
|
|
90
|
+
await refresh(local);
|
|
91
|
+
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");
|
|
92
|
+
const access = await sessionAccess(local.id, request);
|
|
93
|
+
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");
|
|
94
|
+
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") });
|
|
95
|
+
writeFileSync(local.knownHosts, `nomac-${local.id} ${access.ssh.host_key}\n`, { mode: 0o600 }); chmodSync(local.knownHosts, 0o600); store.save(local);
|
|
96
|
+
return local;
|
|
97
|
+
}
|
|
98
|
+
const localSession = () => {
|
|
99
|
+
const local = store.load();
|
|
100
|
+
if (local?.mode !== "hosted" || !local.id) throw new CliError("No attached cloud session.", "Run nomac start to allocate or reconcile your Mac.");
|
|
101
|
+
return local;
|
|
102
|
+
};
|
|
103
|
+
if (command === "session") {
|
|
104
|
+
if (positionals.length !== 1 || !["status", "info"].includes(positionals[0])) throw new CliError("Usage: nomac session status --json");
|
|
105
|
+
const local = localSession(); return output({ session: await refresh(local) });
|
|
106
|
+
}
|
|
107
|
+
if (["exec", "job", "output"].includes(command)) {
|
|
108
|
+
const local = localSession(), base = `/api/v1/mac/sessions/${sessionId(local.id)}`;
|
|
109
|
+
let path, method = "GET", params = {};
|
|
110
|
+
if (command === "exec") {
|
|
111
|
+
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");
|
|
112
|
+
method = "POST"; path = `${base}/jobs`; params = { requestKey: values["request-key"], body: { argv: positionals, timeout_seconds: integer(values.timeout, 60, 1, 600) } };
|
|
113
|
+
} else {
|
|
114
|
+
if (positionals.length !== 1 || !/^job_[a-f0-9]{40}$/.test(positionals[0])) throw new CliError("Supply the existing job ID.");
|
|
115
|
+
path = `${base}/jobs/${positionals[0]}`;
|
|
116
|
+
if (command === "output") {
|
|
117
|
+
const cursor = integer(values.cursor, 0, 0, Number.MAX_SAFE_INTEGER), stream = values.stream ?? "stdout";
|
|
118
|
+
if (!["stdout", "stderr"].includes(stream)) throw new CliError("Use stdout or stderr.");
|
|
119
|
+
path += `/output?cursor=${cursor}&stream=${stream}`;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return output(await hostedRequest(local.id, method, path, params, { request, transport }));
|
|
123
|
+
}
|
|
124
|
+
if (command === "ssh") {
|
|
125
|
+
if (values.json) throw new CliError("SSH streams bytes. Use session status --json for metadata.");
|
|
126
|
+
const local = await ready(localSession());
|
|
127
|
+
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 });
|
|
128
|
+
}
|
|
129
|
+
return store.locked(async () => {
|
|
130
|
+
if (command === "start") {
|
|
131
|
+
if (positionals.length) throw new CliError("Usage: nomac start [--wait SECONDS] [--json]");
|
|
132
|
+
let local = store.load();
|
|
133
|
+
if (local && local.mode !== "hosted") throw new CliError("An existing Mac workspace is attached.");
|
|
134
|
+
const limits = { max_duration_seconds: integer(values["max-duration"], 7200, 60, 86400), idle_timeout_seconds: integer(values["idle-timeout"], 600, 60, 3600), spend_cap_microunits: budget(values["max-cost"]) };
|
|
135
|
+
// Older CLIs persisted invalid keys before contacting the API. Such a key
|
|
136
|
+
// cannot pass server validation and therefore cannot have allocated a VM.
|
|
137
|
+
if (local && !local.id && !validRequestKey(local.requestKey)) { local.state = "failed"; store.save(local); }
|
|
138
|
+
if (local && !["stopped", "failed"].includes(local.state)) {
|
|
139
|
+
for (const [flag, field] of [["max-duration", "max_duration_seconds"], ["idle-timeout", "idle_timeout_seconds"], ["max-cost", "spend_cap_microunits"]]) {
|
|
140
|
+
if (values[flag] !== undefined && limits[field] !== local.request.limits[field]) throw new CliError("This session already has different limits. Stop it and start a fresh session to change them.");
|
|
141
|
+
}
|
|
142
|
+
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.");
|
|
143
|
+
}
|
|
144
|
+
if (!local || ["stopped", "failed"].includes(local.state)) {
|
|
145
|
+
const identity = join(store.directory, `id_ed25519_${randomUUID()}`);
|
|
146
|
+
await run("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-C", "nomac-session", "-f", identity]);
|
|
147
|
+
local = { mode: "hosted", state: "requesting", outcomeUnknown: false, identity, requestKey: values["request-key"] ?? `cli-${randomUUID()}`, request: { profile: "standard", public_key: readFileSync(`${identity}.pub`, "utf8").trim(), limits } };
|
|
148
|
+
store.save(local); store.saveBaseline({});
|
|
149
|
+
}
|
|
150
|
+
if (!local.id) {
|
|
151
|
+
const previouslyUncertain = local.outcomeUnknown !== false;
|
|
152
|
+
local.outcomeUnknown = true; store.save(local); // A crash after dispatch must reconcile this intent.
|
|
153
|
+
let result;
|
|
154
|
+
try { result = await request("POST", "/api/v1/mac/sessions", { idempotencyKey: local.requestKey, body: local.request }); }
|
|
155
|
+
catch (error) {
|
|
156
|
+
if (!previouslyUncertain && startRejections.has(error.code) && Number.isInteger(error.status) && error.status >= 400 && error.status <= 503) {
|
|
157
|
+
local.state = "failed"; local.rejection = error.code; store.save(local);
|
|
158
|
+
}
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
saveView(local, result.session);
|
|
162
|
+
}
|
|
163
|
+
const deadline = now() + wait * 1000;
|
|
164
|
+
for (;;) {
|
|
165
|
+
await refresh(local);
|
|
166
|
+
if (!["queued", "provisioning"].includes(local.state) || now() >= deadline) break;
|
|
167
|
+
await sleep(2000);
|
|
168
|
+
}
|
|
169
|
+
if (local.state === "ready") await ready(local);
|
|
170
|
+
return output({ session: local.snapshot, request_key: local.requestKey });
|
|
171
|
+
}
|
|
172
|
+
const local = localSession();
|
|
173
|
+
if (command === "stop") {
|
|
174
|
+
if (positionals.length) throw new CliError("Usage: nomac stop [--wait SECONDS] [--json]");
|
|
175
|
+
saveView(local, (await request("DELETE", `/api/v1/mac/sessions/${sessionId(local.id)}`)).session);
|
|
176
|
+
const deadline = now() + wait * 1000;
|
|
177
|
+
while (!["stopped", "failed"].includes(local.state) && now() < deadline) { await sleep(1000); await refresh(local); }
|
|
178
|
+
return output({ session: local.snapshot });
|
|
179
|
+
}
|
|
180
|
+
await ready(local);
|
|
181
|
+
if (command === "sync") {
|
|
182
|
+
if (positionals.length > 1 || (positionals[0] && resolve(positionals[0]) !== store.project)) throw new CliError("Run nomac sync . from the attached project.");
|
|
183
|
+
return output(await syncProject(local, store, { overwrite: values.overwrite, dryRun: values["dry-run"], run }));
|
|
184
|
+
}
|
|
185
|
+
if (command === "pull") {
|
|
186
|
+
if (positionals.length !== 1) throw new CliError("Usage: nomac pull LOCAL_PATH [--from REMOTE_PATH]");
|
|
187
|
+
return output(await pullProject(local, store, positionals[0], { from: values.from ?? positionals[0], overwrite: values.overwrite, dryRun: values["dry-run"], run }));
|
|
188
|
+
}
|
|
189
|
+
throw new CliError("Unknown cloud session command.");
|
|
190
|
+
});
|
|
191
|
+
}
|
|
@@ -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 stdout/stderr as base64 chunks; resume from next_cursor. If truncated, the final response includes tail_base64 and its absolute tail_offset. Retrieve promptly: only recent completed jobs retain output. output_expired preserves the job receipt and never means rerun. Retrieve before stopping the VM.", inputSchema: schema({ session_id: session, job_id: job, cursor: { type: "integer", minimum: 0 }, stream: { type: "string", enum: ["stdout", "stderr"] } }, ["session_id", "job_id"]), handler: async args => text(await api("GET", `${base(args)}/jobs/${encodeURIComponent(args.job_id)}/output?${new URLSearchParams({ cursor: String(args.cursor ?? 0), stream: args.stream ?? "stdout" })}`)) },
|
|
18
|
+
];
|
|
@@ -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
|
+
}
|