@nomac/cli 0.5.7 → 0.6.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/package.json +1 -1
- package/skill/SKILL.md +17 -150
- package/src/cli.mjs +28 -40
- package/src/hosted-sessions.mjs +108 -11
- package/src/mac-fetch.mjs +28 -0
- package/src/mac-links.mjs +51 -0
- package/src/mac-tools.mjs +45 -1
- package/src/mcp.mjs +15 -6
- package/src/session-ssh.mjs +22 -3
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -3,8 +3,7 @@ name: nomac
|
|
|
3
3
|
description: >
|
|
4
4
|
Use a full cloud Mac over SSH, HTTPS or MCP for macOS tools, Xcode and iOS
|
|
5
5
|
development. Start sessions, sync files, run commands, retrieve work and
|
|
6
|
-
stop.
|
|
7
|
-
App Store publishing, signing, screenshots, metadata and review replies.
|
|
6
|
+
stop.
|
|
8
7
|
---
|
|
9
8
|
|
|
10
9
|
# nomac — a Mac for your agent
|
|
@@ -25,9 +24,15 @@ commands and tools. Mac time is prepaid credit, separate from Builder.
|
|
|
25
24
|
the printed device URL and code. MCP: `claude mcp add nomac -- npx @nomac/cli mcp`.
|
|
26
25
|
Hosted MCP: https://mcp.nomac.app/mcp with `Authorization: Bearer <nmk_key>`.
|
|
27
26
|
3. `nomac start --json`, wait until ready, `nomac sync .`, then `nomac ssh`.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
`
|
|
27
|
+
Run commands with `nomac exec -- COMMAND`: it prints the output and exits
|
|
28
|
+
with the command's code, waiting at most 30 s. For builds and tests use
|
|
29
|
+
`nomac exec --wait 600 -- COMMAND`. Exit code 75 means still running: wait
|
|
30
|
+
with `nomac job JOB_ID --wait 600`, never rerun. In scripts add
|
|
31
|
+
`--request-key stable-key-0001`. Over ten minutes, use `nomac ssh -- COMMAND`. MCP equivalents are `start_mac`, `exec_mac` (also waits),
|
|
32
|
+
`write_mac_file`, `read_mac_file`, `fetch_to_mac`, `stop_mac`. For a file
|
|
33
|
+
up to 2 GiB from the human, or to hand them a build, use
|
|
34
|
+
`create_mac_upload_link` and `create_mac_download_link`: each returns a url
|
|
35
|
+
they open in a browser.
|
|
31
36
|
4. Download work with `nomac pull ./build` (use `--from PATH` for another
|
|
32
37
|
remote directory), then `nomac stop --json`. Confirm `cleanup_confirmed`.
|
|
33
38
|
|
|
@@ -40,155 +45,17 @@ Keep the same request key and arguments after uncertain responses; inspect
|
|
|
40
45
|
the original job before retrying. Output retrieval failure is not job failure.
|
|
41
46
|
Managed jobs have a 600-second limit; SSH can run longer within session limits.
|
|
42
47
|
CLI sync skips symlinks and preserves remote extra files; use native SSH/rsync
|
|
43
|
-
when needed. GUI
|
|
48
|
+
when needed. Agents have no GUI control: drive the Simulator with simctl, and
|
|
49
|
+
give the human `open_mac_desktop` when a screen needs a person. Persistent disks
|
|
50
|
+
are not currently offered.
|
|
44
51
|
Report failures using `report_issue` with `session_id`, optional `job_id` and
|
|
45
52
|
a description without secrets. Read https://nomac.app/llms.txt for full limits.
|
|
46
53
|
|
|
47
|
-
##
|
|
54
|
+
## Builder (existing subscribers only)
|
|
48
55
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
## Setup check
|
|
54
|
-
|
|
55
|
-
1. MCP tools available (`connect_status`, `push_project`, `build`, …)? Use them.
|
|
56
|
-
2. Otherwise: `npx @nomac/cli whoami`. If not logged in, run
|
|
57
|
-
`npx @nomac/cli login`; give the human the device URL and code to approve.
|
|
58
|
-
Then `claude mcp add nomac -- npx @nomac/cli mcp`.
|
|
59
|
-
3. `connect_status` must show a healthy Apple connection. If not, the human
|
|
60
|
-
connects at the dashboard (60 seconds, one .p8 upload). Don't guess —
|
|
61
|
-
verify, then proceed.
|
|
62
|
-
|
|
63
|
-
## Ship to TestFlight
|
|
64
|
-
|
|
65
|
-
```
|
|
66
|
-
push_project # after every code change
|
|
67
|
-
build workflow=release # ~3 min; smoke = fast compile-only check
|
|
68
|
-
status # poll ~30s until ready | failed
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
- `ready` → tell the human: "open TestFlight on your phone." Their taps are
|
|
72
|
-
the test run; read `get_feedback` for crashes/comments afterwards.
|
|
73
|
-
- `failed` → `get_failure`: kind=project means you fix source and re-push;
|
|
74
|
-
kind=account means relay the link to the human. Don't burn release builds
|
|
75
|
-
debugging compile errors — use `build workflow=smoke`.
|
|
76
|
-
|
|
77
|
-
## Submit for App Store review
|
|
78
|
-
|
|
79
|
-
1. `review_lint` → fix every red. Apply returned patches (e.g. generated
|
|
80
|
-
`PrivacyInfo.xcprivacy`) directly to the source. Yellow "signals"
|
|
81
|
-
(4.3 distinctiveness, thin listing) are yours to judge and improve.
|
|
82
|
-
2. `set_metadata` — you write the copy; check `get_metadata_schema` for
|
|
83
|
-
limits. Age rating + data usage answers are legal attestations: ask the
|
|
84
|
-
human, relay their answers. `upload_screenshots` needs exact dimensions
|
|
85
|
-
(in the schema). It runs in the background: poll `get_screenshot_upload`
|
|
86
|
-
until complete before publishing. A rolled-back or paused replacement
|
|
87
|
-
needs the returned recovery instructions before starting another upload.
|
|
88
|
-
3. `publish` without confirm stages real Apple drafts/build attachments but
|
|
89
|
-
does not submit. Fix the returned blockers; retain the exact build/submission IDs.
|
|
90
|
-
4. `publish confirm=true` sends the app to Apple. Ensure the human authorized
|
|
91
|
-
this app/version; honor authorization already given. New versions use manual
|
|
92
|
-
release, while existing versions retain their Apple release policy.
|
|
93
|
-
|
|
94
|
-
If metadata or publish returns pending, keep its `request_key` and repeat
|
|
95
|
-
the original arguments with that key. `get_store_operation` recovers the key
|
|
96
|
-
and progress after a lost response. A new key starts a separate operation;
|
|
97
|
-
do not use one to bypass an unconfirmed Apple request. Staging and confirmation
|
|
98
|
-
remain separate invocations. For screenshots, keep the original `request_key`
|
|
99
|
-
too, and use `get_screenshot_upload` to find or poll the existing operation.
|
|
100
|
-
|
|
101
|
-
## Fix review rejections and release
|
|
102
|
-
|
|
103
|
-
- `get_review` lists live Apple submissions, including ones created outside
|
|
104
|
-
nomac. Pass `asc_submission_id` for all item states and exact version/build IDs.
|
|
105
|
-
- Apple does not expose App Review messages through its public API. Use the
|
|
106
|
-
returned App Store Connect handoff to have the human provide the actual text.
|
|
107
|
-
Never invent a rejection reason from a status code or public customer review.
|
|
108
|
-
- Fix metadata/screenshots on the exact `version_string`. A binary change needs
|
|
109
|
-
push/build, then `publish` with the exact `build_id` and `asc_submission_id`.
|
|
110
|
-
`resolve_rejection:true` explicitly asserts that this version's issue is fixed.
|
|
111
|
-
- `prepare_review_reply` prepares your authored text but always returns `sent:false`.
|
|
112
|
-
The human sends it and any message attachments in App Store Connect. Changing
|
|
113
|
-
review_contact.notes is not a reply; sending a reply is not resubmission.
|
|
114
|
-
- `update_review` supports `resolve_item`, `remove_item`, `cancel`, `resubmit`,
|
|
115
|
-
and `release`. Preview without confirm; confirm the exact requested action.
|
|
116
|
-
Fix issues before marking items resolved. Every rejected item must be resolved
|
|
117
|
-
or removed before resubmitting. Removed items cannot be added back to that submission.
|
|
118
|
-
- Poll `get_review`; approval and release are separate. Release requires an
|
|
119
|
-
app-version item in Pending Developer Release. Edit subscription/IAP products,
|
|
120
|
-
events and custom pages through the store catalog below. Apple/account/legal
|
|
121
|
-
decisions still require the relevant Apple workflow.
|
|
122
|
-
|
|
123
|
-
## External TestFlight
|
|
124
|
-
|
|
125
|
-
`ready` from a nomac build means internal TestFlight readiness. External testing
|
|
126
|
-
requires its own review and distribution:
|
|
127
|
-
|
|
128
|
-
1. `get_testflight` lists groups, test information and recent Apple builds. Use
|
|
129
|
-
`asc_build_id` for a specific build's beta-review/external state, or `group_id`
|
|
130
|
-
to list testers. Never infer external approval from internal readiness.
|
|
131
|
-
2. `manage_testflight action=metadata` sets app_info (description/feedbackEmail/URLs),
|
|
132
|
-
review_contact (Apple contact/demo fields), locale, and build whats_new.
|
|
133
|
-
3. Select/create an external group. `invite_tester`/`remove_tester` use an explicit
|
|
134
|
-
email/tester ID and group_id. `update_group` can enable an optional public link.
|
|
135
|
-
4. `distribute` requires asc_build_id and explicit group_ids. It submits beta
|
|
136
|
-
review if needed, then assigns those groups. auto_notify defaults false;
|
|
137
|
-
`notify` starts testing after approval. `remove_build` affects one group;
|
|
138
|
-
`expire` stops testing for the build across all groups.
|
|
139
|
-
5. All actions preview without confirm and apply with confirm:true. Pending
|
|
140
|
-
requests use their original key/body; get_store_operation recovers them.
|
|
141
|
-
6. Relay an accurate human-provided uses_non_exempt_encryption with action=encryption
|
|
142
|
-
when appropriate. Never guess. Encryption declarations/documents use the
|
|
143
|
-
store catalog; rejected beta messages require App Store Connect. Fix the cause
|
|
144
|
-
before resubmitting a rejected binary.
|
|
145
|
-
|
|
146
|
-
## Pricing, products, customer reviews and the wider store API
|
|
147
|
-
|
|
148
|
-
1. `get_store_capabilities` lists operations by category/search with next_offset.
|
|
149
|
-
Pass `operation` to get that operation's exact Apple request/query schema.
|
|
150
|
-
Categories: listing, releases, pricing, purchases, subscriptions, reviews,
|
|
151
|
-
marketing, assets, testflight, analytics, compliance, webhooks.
|
|
152
|
-
2. `read_store` executes listed GET operations. Begin with an `apps_*` relationship
|
|
153
|
-
and `scope:[]` for the linked app. Every child has a reusable `scope`; copy it
|
|
154
|
-
into the next read/write. Keep the query when following `next_cursor`.
|
|
155
|
-
3. `update_store` takes the operation, scope and exact JSON:API `body`. For creation,
|
|
156
|
-
scope the parent. Every existing relationship ID needs its own verified scope
|
|
157
|
-
in `references`; the target and its ancestors are already verified. Territories
|
|
158
|
-
and categories are global. Inline creates use local IDs such as `${price1}`.
|
|
159
|
-
4. Preview without confirm. `confirm:true` applies real paid prices, availability,
|
|
160
|
-
products/offers, public responses, release settings or event subscriptions.
|
|
161
|
-
Honor the human's existing authorization; do not ask again for authorized work.
|
|
162
|
-
Do not invent prices, legal answers or factual claims in public replies.
|
|
163
|
-
5. `customerReviewResponses_createInstance` replies to a public customer review.
|
|
164
|
-
It does not send a message to Apple reviewers. Read their actual messages in
|
|
165
|
-
App Store Connect; `prepare_review_reply` only drafts the response.
|
|
166
|
-
6. Product management covers subscriptions/IAP versions, localization, prices,
|
|
167
|
-
availability, grace periods and offers. First IAP approval may require the
|
|
168
|
-
app version submission. Draft review submission items can attach products;
|
|
169
|
-
use `publish` / `update_review` to submit, resolve, resubmit and release.
|
|
170
|
-
7. Events, custom pages, experiments, accessibility and phased releases use their
|
|
171
|
-
catalog operations. Apple still validates account permissions and valid states.
|
|
172
|
-
Analytics report segments supply download URLs; offer-code CSV exports return
|
|
173
|
-
bounded base64 downloads. Webhooks send app events to an owner-selected receiver;
|
|
174
|
-
that receiver is responsible for running an agent when events arrive.
|
|
175
|
-
|
|
176
|
-
`upload_store_asset` handles new Apple file resources: previews, screenshot files,
|
|
177
|
-
purchase images, supplemental review attachments and encryption documents. Choose
|
|
178
|
-
the asset create operation, provide its parent relationship/body, and pass a local
|
|
179
|
-
`file_path` (CLI, up to 500 MiB) or `data_base64` (hosted, up to 2 MiB). Apple also
|
|
180
|
-
has asset-specific limits. The tool reserves, uploads each requested byte range,
|
|
181
|
-
then commits its checksum. Keep the same request_key and original bytes to resume.
|
|
182
|
-
`uploaded:true` is not processing success: poll `read_store` until
|
|
183
|
-
`assetDeliveryState.state` is `COMPLETE`. FAILED requires addressing Apple's errors.
|
|
184
|
-
This adds a new file; use `upload_screenshots` for managed screenshot replacement.
|
|
185
|
-
|
|
186
|
-
Keep pending write keys and unchanged bodies. `get_store_operation` exposes captured
|
|
187
|
-
targets and receipts. Unknown Apple outcomes remain pending and block other writes
|
|
188
|
-
to the app until reconciled; never use a fresh key to bypass one. Corrected inputs
|
|
189
|
-
or a new intentional action require a fresh key. Correspondence, appeals, App Privacy
|
|
190
|
-
questionnaire answers and account-wide financial/admin APIs are not exposed.
|
|
191
|
-
This catalog does not run simulators or Xcode Cloud jobs.
|
|
56
|
+
The separate iOS Builder publishing product is closed to new subscriptions.
|
|
57
|
+
If `get_billing_status` shows a Builder plan, its tools are listed and
|
|
58
|
+
https://nomac.app/llms.txt describes them. Otherwise use the cloud Mac above.
|
|
192
59
|
|
|
193
60
|
## When stuck
|
|
194
61
|
|
package/src/cli.mjs
CHANGED
|
@@ -4,42 +4,28 @@ 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: a cloud Mac for you and your agents ($0.80 an hour, by the minute)
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
nomac
|
|
11
|
-
nomac credits buy --packs N
|
|
12
|
-
nomac start
|
|
13
|
-
nomac
|
|
14
|
-
nomac job JOB_ID inspect a submitted command
|
|
15
|
-
nomac output JOB_ID read resumable command output
|
|
16
|
-
nomac desktop the Mac's full desktop in your browser
|
|
17
|
-
nomac start --help session limits and usage
|
|
18
|
-
|
|
19
|
-
Existing Mac workspaces:
|
|
20
|
-
nomac start --host user@mac connect a dedicated Mac you control
|
|
21
|
-
nomac sync . upload changed project files
|
|
22
|
-
nomac ssh open the full Mac shell
|
|
23
|
-
nomac pull ./output retrieve a remote directory
|
|
24
|
-
nomac session status --json show workspace and connection information
|
|
25
|
-
nomac stop detach locally (does not end a rental)
|
|
26
|
-
nomac start --help setup, exclusions and preview limitations
|
|
9
|
+
Start:
|
|
10
|
+
nomac login connect this device (approve in the browser)
|
|
11
|
+
nomac credits credit left; nomac credits buy --packs N adds more
|
|
12
|
+
nomac start start a Mac, or reconnect to yours
|
|
13
|
+
nomac stop stop the Mac: deletes it and its files, billing ends
|
|
27
14
|
|
|
28
|
-
|
|
29
|
-
nomac
|
|
30
|
-
nomac
|
|
15
|
+
Use it:
|
|
16
|
+
nomac exec -- ls run a command; prints its output, keeps its exit code
|
|
17
|
+
nomac exec --wait 600 -- xcodebuild test
|
|
18
|
+
wait up to 10 minutes (the default is 30 seconds)
|
|
19
|
+
nomac ssh open a shell (nomac ssh -- command runs one)
|
|
20
|
+
nomac sync . upload changed project files
|
|
21
|
+
nomac pull ./build download a folder from the Mac
|
|
22
|
+
nomac desktop the Mac's desktop in your browser
|
|
23
|
+
nomac status state, cost so far and when it stops
|
|
31
24
|
|
|
32
|
-
|
|
33
|
-
nomac
|
|
34
|
-
nomac login <api-key> store an nmk_ key directly (CI / power users)
|
|
35
|
-
nomac push pack this directory and upload a snapshot
|
|
36
|
-
nomac build [--smoke] start a release (or smoke) build
|
|
37
|
-
nomac build --testflight release build; waits until TestFlight-ready
|
|
38
|
-
nomac status [build-id] show latest (or given) build status
|
|
39
|
-
nomac logs <build-id> show build logs
|
|
40
|
-
nomac mcp run the stdio MCP server (for agents)
|
|
41
|
-
nomac whoami show org + usage
|
|
25
|
+
Agents:
|
|
26
|
+
nomac mcp MCP server for Claude Code, Codex, Cursor and others
|
|
42
27
|
|
|
28
|
+
More: nomac start --help, nomac whoami, nomac start --host user@mac (a Mac you own)
|
|
43
29
|
Environment: NOMAC_API_KEY, NOMAC_API_URL`;
|
|
44
30
|
|
|
45
31
|
export async function main(argv) {
|
|
@@ -61,6 +47,10 @@ export async function main(argv) {
|
|
|
61
47
|
const { transportCommand } = await import("./transport-command.mjs");
|
|
62
48
|
return transportCommand(rest);
|
|
63
49
|
}
|
|
50
|
+
case "status":
|
|
51
|
+
// A legacy build ID, or a folder that only ever built with the old pipeline, keeps build status.
|
|
52
|
+
if (/^bld_/.test(rest[0] ?? "") || (loadProjectState().last_build && !(await import("./session-store.mjs")).sessionStore().load())) return status(rest[0]);
|
|
53
|
+
// falls through: everyone else sees their Mac
|
|
64
54
|
case "start":
|
|
65
55
|
case "exec":
|
|
66
56
|
case "job":
|
|
@@ -78,7 +68,8 @@ export async function main(argv) {
|
|
|
78
68
|
return hostedSessionCommand(cmd, rest, { store });
|
|
79
69
|
}
|
|
80
70
|
const { sessionCommand } = await import("./sessions.mjs");
|
|
81
|
-
|
|
71
|
+
// A Mac you own reports through session status.
|
|
72
|
+
return cmd === "status" ? sessionCommand("session", ["status", ...rest]) : sessionCommand(cmd, rest);
|
|
82
73
|
}
|
|
83
74
|
case "login": {
|
|
84
75
|
const key = rest[0];
|
|
@@ -99,8 +90,6 @@ export async function main(argv) {
|
|
|
99
90
|
return push();
|
|
100
91
|
case "build":
|
|
101
92
|
return build(rest);
|
|
102
|
-
case "status":
|
|
103
|
-
return status(rest[0]);
|
|
104
93
|
case "logs":
|
|
105
94
|
return logs(rest[0]);
|
|
106
95
|
case "mcp": {
|
|
@@ -108,12 +97,11 @@ export async function main(argv) {
|
|
|
108
97
|
return runMcpServer();
|
|
109
98
|
}
|
|
110
99
|
case "whoami": {
|
|
111
|
-
const me = await api("GET", "/api/v1/me");
|
|
112
|
-
const o = me.org ?? {};
|
|
100
|
+
const [me, credit] = await Promise.all([api("GET", "/api/v1/me"), api("GET", "/api/v1/mac/credits").catch(() => null)]);
|
|
101
|
+
const o = me.org ?? {}, c = credit?.credits ?? credit ?? {};
|
|
113
102
|
console.log(`org: ${o.name ?? "?"} (${o.id ?? "?"})`);
|
|
114
|
-
console.log(`
|
|
115
|
-
console.log(`
|
|
116
|
-
console.log(`connections: ${me.connections ?? 0}`);
|
|
103
|
+
if (c.spendable_microunits !== undefined) console.log(`Mac credit: $${(Number(c.spendable_microunits) / 1_000_000).toFixed(2)} (${Math.floor((c.spendable_seconds ?? 0) / 3600)}h ${String(Math.floor(((c.spendable_seconds ?? 0) % 3600) / 60)).padStart(2, "0")}m of Mac time)`);
|
|
104
|
+
if (o.tier && o.tier !== "none") console.log(`Builder: ${o.tier} (${me.projects ?? 0} projects)`);
|
|
117
105
|
console.log(`auth: ${me.auth?.via ?? "?"}${me.auth?.key_id ? ` (${me.auth.key_id})` : ""}`);
|
|
118
106
|
return;
|
|
119
107
|
}
|
package/src/hosted-sessions.mjs
CHANGED
|
@@ -15,12 +15,16 @@ const HELP = `Nomac cloud Mac sessions
|
|
|
15
15
|
nomac ssh [--forward 8080:3000] full Mac shell and port forwarding
|
|
16
16
|
nomac ssh -- command args run any command over SSH
|
|
17
17
|
nomac desktop [--no-open] [--json] the full macOS desktop in your browser
|
|
18
|
-
nomac exec --
|
|
19
|
-
|
|
18
|
+
nomac exec [--wait 30] -- command args
|
|
19
|
+
runs it, prints its output, exits with its code.
|
|
20
|
+
Waits up to 30 s; for builds and tests use --wait 600.
|
|
21
|
+
Exit code 75 means still running: see nomac job.
|
|
22
|
+
(--request-key KEY makes a scripted retry safe)
|
|
23
|
+
nomac job JOB_ID [--wait 600] wait for a command that outlasted --wait
|
|
20
24
|
nomac output JOB_ID [--cursor 0] [--stream stdout|stderr]
|
|
21
25
|
nomac pull ./output [--from PATH] retrieve files before stopping
|
|
22
|
-
nomac
|
|
23
|
-
nomac stop [--wait 60] --json
|
|
26
|
+
nomac status [--json] state, cost so far and when it stops
|
|
27
|
+
nomac stop [--wait 60] [--json] stop the Mac: deletes it and its files
|
|
24
28
|
|
|
25
29
|
Login once with nomac login. The CLI creates a local SSH key; it sends only the
|
|
26
30
|
public key. Sessions use the port-443 gateway and verified guest host keys.
|
|
@@ -63,11 +67,88 @@ export async function sessionAccess(id, request = api) {
|
|
|
63
67
|
transportOptions({ gateway: access.gateway, token: access.token });
|
|
64
68
|
return access;
|
|
65
69
|
}
|
|
66
|
-
|
|
67
|
-
|
|
70
|
+
/** nomac exec's exit code for a command still running when the wait ends (EX_TEMPFAIL). */
|
|
71
|
+
export const STILL_RUNNING = 75;
|
|
72
|
+
/** Quick commands are checked early; after that, once a second. */
|
|
73
|
+
const POLL_MS = [250, 500];
|
|
74
|
+
const NOTICE_MS = 5000;
|
|
75
|
+
/**
|
|
76
|
+
* nomac exec waits like a local command: up to --wait seconds (default 30), then
|
|
77
|
+
* prints stdout and stderr and exits with the command's own exit code. The wait
|
|
78
|
+
* is a ceiling: a quick command returns as soon as it finishes. A command still
|
|
79
|
+
* running keeps running on the Mac; the CLI says how to wait for it and exits
|
|
80
|
+
* 75. --wait 0 only starts it.
|
|
81
|
+
*/
|
|
82
|
+
async function waitForCommand(id, base, started, waitSeconds, { request, transport, cache, print, stdout, stderr, exitWith, sleep, now, json }) {
|
|
83
|
+
let job = started;
|
|
84
|
+
const deadline = now() + waitSeconds * 1000;
|
|
85
|
+
const begun = now();
|
|
86
|
+
let announced = false;
|
|
87
|
+
for (let polls = 0; waitSeconds && !["exited", "unknown"].includes(job.state) && now() < deadline; polls++) {
|
|
88
|
+
// A slow command can outlive the shell waiting for it (agent shells time out); leave a way back.
|
|
89
|
+
if (!json && !announced && now() - begun >= NOTICE_MS && deadline - now() > 1000) {
|
|
90
|
+
announced = true;
|
|
91
|
+
stderr(`nomac: ${job.id} is still running; waiting up to ${waitSeconds} s. If interrupted: nomac job ${job.id} --wait 600\n`);
|
|
92
|
+
}
|
|
93
|
+
await sleep(Math.min(POLL_MS[polls] ?? 1000, Math.max(0, deadline - now())));
|
|
94
|
+
job = await hostedRequest(id, "GET", `${base}/jobs/${job.id}`, {}, { request, transport, cache });
|
|
95
|
+
}
|
|
96
|
+
if (job.state !== "exited") {
|
|
97
|
+
if (!waitSeconds) { print(JSON.stringify(job, null, 2)); return job; }
|
|
98
|
+
const unknown = job.state === "unknown";
|
|
99
|
+
const next = unknown ? `The Mac could not confirm whether ${job.id} finished. Inspect it with nomac job ${job.id}; do not rerun it.`
|
|
100
|
+
: `Still running after ${waitSeconds} s; it keeps running on the Mac. Wait for it: nomac job ${job.id} --wait 600. Next time wait up front: nomac exec --wait 600 -- command. Over 10 minutes: nomac ssh -- command. Do not rerun it.`;
|
|
101
|
+
if (json) print(JSON.stringify({ ...job, next_step: next }, null, 2));
|
|
102
|
+
else if (unknown) stderr(`nomac: ${next}\n`);
|
|
103
|
+
else stderr(`nomac: still running after ${waitSeconds} s (${job.id}); it keeps running on the Mac.\n wait for it: nomac job ${job.id} --wait 600\n next time, wait longer: nomac exec --wait 600 -- command\n over 10 minutes: nomac ssh -- command\n`);
|
|
104
|
+
exitWith(unknown ? 1 : STILL_RUNNING);
|
|
105
|
+
return job;
|
|
106
|
+
}
|
|
107
|
+
// Each read is a round trip to the Mac: skip empty streams and read the others together.
|
|
108
|
+
const read = async stream => {
|
|
109
|
+
if (job.output_bytes?.[stream] === 0) return Buffer.alloc(0);
|
|
110
|
+
const parts = [];
|
|
111
|
+
for (let cursor = 0; ;) {
|
|
112
|
+
const part = await hostedRequest(id, "GET", `${base}/jobs/${job.id}/output?cursor=${cursor}&stream=${stream}`, {}, { request, transport, cache });
|
|
113
|
+
parts.push(Buffer.from(part.data_base64 ?? "", "base64"));
|
|
114
|
+
if (part.eof && part.tail_base64) parts.push(Buffer.from(`\n[output over 8 MiB was not kept; its last part follows]\n`), Buffer.from(part.tail_base64, "base64"));
|
|
115
|
+
if (part.eof || part.next_cursor === cursor) break;
|
|
116
|
+
cursor = part.next_cursor;
|
|
117
|
+
}
|
|
118
|
+
return Buffer.concat(parts);
|
|
119
|
+
};
|
|
120
|
+
const [out, err] = await Promise.all([read("stdout"), read("stderr")]);
|
|
121
|
+
const streams = { stdout: out, stderr: err };
|
|
122
|
+
if (json) print(JSON.stringify({ ...job, stdout: streams.stdout.toString("utf8"), stderr: streams.stderr.toString("utf8") }, null, 2));
|
|
123
|
+
else { stdout(streams.stdout); stderr(streams.stderr); }
|
|
124
|
+
exitWith(typeof job.exit_code === "number" ? job.exit_code : 1);
|
|
125
|
+
return job;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const usd = microunits => `$${(Number(microunits ?? 0) / 1_000_000).toFixed(2)}`;
|
|
129
|
+
const clockUtc = ms => new Date(ms).toISOString().slice(11, 16) + " UTC";
|
|
130
|
+
/** One readable paragraph for nomac status. */
|
|
131
|
+
export function statusSummary(session) {
|
|
132
|
+
const lines = [`${session.id}: ${session.state}`];
|
|
133
|
+
if (["ready", "provisioning", "queued"].includes(session.state)) lines.push(`Cost so far: ${usd(session.cost?.accrued_microunits)} ($0.80/hour, billed by the minute while ready). Credit left: nomac credits`);
|
|
134
|
+
const a = session.activity ?? {};
|
|
135
|
+
if (session.state === "ready") {
|
|
136
|
+
const stops = [a.idle_deadline_at ? `when idle, at ${clockUtc(a.idle_deadline_at)}` : null, a.hard_deadline_at ? `at ${clockUtc(a.hard_deadline_at)} at the latest` : null].filter(Boolean);
|
|
137
|
+
if (stops.length) lines.push(`Stops by itself ${stops.join(", or ")}. Stop now: nomac stop`);
|
|
138
|
+
}
|
|
139
|
+
if (session.failure_code) lines.push(`Ended: ${session.failure_code}`);
|
|
140
|
+
return lines.join("\n");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** With a cache, one access token serves a whole command until shortly before it expires. */
|
|
144
|
+
export async function hostedRequest(id, method, path, params = {}, { request = api, transport = macRequest, cache } = {}) {
|
|
145
|
+
const reusable = cache?.access && Date.parse(cache.access.expires_at) - Date.now() > 30_000;
|
|
146
|
+
const access = reusable ? cache.access : await sessionAccess(id, request);
|
|
147
|
+
if (cache) cache.access = access;
|
|
68
148
|
return transport(method, path, { ...params, gateway: access.gateway, token: access.token });
|
|
69
149
|
}
|
|
70
|
-
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
|
|
150
|
+
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,
|
|
151
|
+
stdout = data => process.stdout.write(data), stderr = data => process.stderr.write(data), exitWith = code => { process.exitCode = code; } } = {}) {
|
|
71
152
|
let parsed;
|
|
72
153
|
try { parsed = parseArgs({ args: argv, options, allowPositionals: true }); }
|
|
73
154
|
catch (error) { throw new CliError(error.message); }
|
|
@@ -105,15 +186,28 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
105
186
|
return local;
|
|
106
187
|
};
|
|
107
188
|
if (command === "session") {
|
|
108
|
-
if (positionals.length !== 1 || !["status", "info"].includes(positionals[0])) throw new CliError("Usage: nomac
|
|
189
|
+
if (positionals.length !== 1 || !["status", "info"].includes(positionals[0])) throw new CliError("Usage: nomac status [--json]");
|
|
109
190
|
const local = localSession(); return output({ session: await refresh(local) });
|
|
110
191
|
}
|
|
192
|
+
if (command === "status") {
|
|
193
|
+
if (positionals.length) throw new CliError("Usage: nomac status [--json]");
|
|
194
|
+
const loaded = store.load();
|
|
195
|
+
if (!loaded) { print(values.json ? JSON.stringify({ session: null }) : "No Mac in this folder. Start one: nomac start"); return null; }
|
|
196
|
+
const session = await refresh(localSession());
|
|
197
|
+
if (values.json) { print(JSON.stringify({ session }, null, 2)); return session; }
|
|
198
|
+
print(statusSummary(session));
|
|
199
|
+
return session;
|
|
200
|
+
}
|
|
111
201
|
if (["exec", "job", "output"].includes(command)) {
|
|
112
202
|
const local = localSession(), base = `/api/v1/mac/sessions/${sessionId(local.id)}`;
|
|
113
203
|
let path, method = "GET", params = {};
|
|
114
204
|
if (command === "exec") {
|
|
115
|
-
if (!positionals.length || !argv.includes("--")
|
|
116
|
-
|
|
205
|
+
if (!positionals.length || !argv.includes("--")) throw new CliError("Use nomac exec -- command args");
|
|
206
|
+
if (values["request-key"] !== undefined && !/^[A-Za-z0-9_.:-]{8,128}$/.test(values["request-key"])) throw new CliError("A request key is 8 to 128 letters, digits, underscores, periods, colons or hyphens.");
|
|
207
|
+
// A person running a command once needs no key; scripts pass one so a lost response can be retried safely.
|
|
208
|
+
// A long wait implies a long command, so the command's own limit follows it unless set.
|
|
209
|
+
const waitFor = integer(values.wait, 30, 0, 600);
|
|
210
|
+
method = "POST"; path = `${base}/jobs`; params = { requestKey: values["request-key"] ?? `cli-${randomUUID()}`, body: { argv: positionals, timeout_seconds: integer(values.timeout, Math.max(300, waitFor), 1, 600) } };
|
|
117
211
|
} else {
|
|
118
212
|
if (positionals.length !== 1 || !/^job_[a-f0-9]{40}$/.test(positionals[0])) throw new CliError("Supply the existing job ID.");
|
|
119
213
|
path = `${base}/jobs/${positionals[0]}`;
|
|
@@ -123,7 +217,10 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
123
217
|
path += `/output?cursor=${cursor}&stream=${stream}`;
|
|
124
218
|
}
|
|
125
219
|
}
|
|
126
|
-
|
|
220
|
+
const cache = {}, result = await hostedRequest(local.id, method, path, params, { request, transport, cache });
|
|
221
|
+
const waiting = command === "exec" || (command === "job" && values.wait !== undefined);
|
|
222
|
+
if (!waiting) return output(result);
|
|
223
|
+
return waitForCommand(local.id, base, result, integer(values.wait, 30, 0, 600), { request, transport, cache, print, stdout, stderr, exitWith, sleep, now, json: Boolean(values.json) });
|
|
127
224
|
}
|
|
128
225
|
if (command === "desktop") {
|
|
129
226
|
if (positionals.length) throw new CliError("Usage: nomac desktop [--no-open] [--json]");
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fetch_to_mac runs this fixed script as an ordinary job. The URL and paths
|
|
3
|
+
* arrive as environment variables, so nothing a caller sends is parsed as shell.
|
|
4
|
+
* Keep identical to apps/web/lib/mac-fetch.ts (a web test compares them).
|
|
5
|
+
*/
|
|
6
|
+
export const FETCH_SCRIPT = [
|
|
7
|
+
"set -euo pipefail",
|
|
8
|
+
'dest="$NOMAC_PATH"',
|
|
9
|
+
'case "$dest" in "~") dest="$HOME" ;; "~/"*) dest="$HOME/${dest:2}" ;; esac',
|
|
10
|
+
'tmp="$(mktemp "${TMPDIR:-/tmp}/nomac-fetch.XXXXXX")"',
|
|
11
|
+
"trap 'rm -f \"$tmp\"' EXIT",
|
|
12
|
+
'curl -fsSL --retry 3 --retry-all-errors --max-time 590 -o "$tmp" -- "$NOMAC_URL"',
|
|
13
|
+
'case "${NOMAC_EXTRACT:-}" in',
|
|
14
|
+
' zip) mkdir -p "$dest"; ditto -x -k "$tmp" "$dest" ;;',
|
|
15
|
+
' tar) mkdir -p "$dest"; tar -xf "$tmp" -C "$dest" ;;',
|
|
16
|
+
' *) mkdir -p "$(dirname "$dest")"; chmod "${NOMAC_MODE:-644}" "$tmp"; mv "$tmp" "$dest"; trap - EXIT ;;',
|
|
17
|
+
"esac",
|
|
18
|
+
'if [ -d "$dest" ]; then echo "unpacked into $dest: $(find "$dest" -type f | wc -l | tr -d " ") files"; else echo "saved $dest: $(stat -f %z "$dest") bytes"; fi',
|
|
19
|
+
].join("\n");
|
|
20
|
+
|
|
21
|
+
/** Image types an agent can look at, recognized by their first bytes. */
|
|
22
|
+
export function imageType(bytes) {
|
|
23
|
+
if (bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "image/png";
|
|
24
|
+
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
|
|
25
|
+
if (bytes.subarray(0, 4).toString("latin1") === "GIF8") return "image/gif";
|
|
26
|
+
if (bytes.subarray(0, 4).toString("latin1") === "RIFF" && bytes.subarray(8, 12).toString("latin1") === "WEBP") return "image/webp";
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createWriteStream, mkdirSync, openAsBlob, renameSync, rmSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { Readable } from "node:stream";
|
|
4
|
+
import { pipeline } from "node:stream/promises";
|
|
5
|
+
import { api, CliError } from "./api.mjs";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Upload and download links move files of up to 2 GiB through storage, so a
|
|
9
|
+
* file from this machine goes to the Mac (or back) without passing through the
|
|
10
|
+
* agent or the API's request size limit.
|
|
11
|
+
*/
|
|
12
|
+
export const MAX_LINK_BYTES = 2 * 1024 ** 3;
|
|
13
|
+
/** The secret part of a link's url, or a bare token. */
|
|
14
|
+
export const linkToken = link => /(?:^|\/)([A-Za-z0-9_-]{32})(?:\/file)?\/?(?:[?#].*)?$/.exec(String(link).trim())?.[1];
|
|
15
|
+
const sessionBase = id => `/api/v1/mac/sessions/${encodeURIComponent(id)}`;
|
|
16
|
+
|
|
17
|
+
export function createLink(sessionId, body) {
|
|
18
|
+
return api("POST", `${sessionBase(sessionId)}/links`, { body });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function checkLink(link, wait = 20) {
|
|
22
|
+
const token = linkToken(link);
|
|
23
|
+
if (!token) throw new CliError("invalid link", "Pass the link's url as returned when it was made.", 400, "invalid_request", { code: "invalid_request" });
|
|
24
|
+
return api("GET", `/api/v1/mac/links/${token}?wait=${wait}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Sends a local file to the Mac: straight to storage, then the Mac fetches it. */
|
|
28
|
+
export async function uploadThroughLink(sessionId, localPath, { path, executable = false, extract, wait = 50, fetchImpl = fetch } = {}) {
|
|
29
|
+
const file = resolve(localPath), size = statSync(file).size;
|
|
30
|
+
if (size > MAX_LINK_BYTES) throw new CliError("file too large", `${localPath} is over 2 GiB, the most a link takes. Split it, or have the Mac download it with fetch_to_mac.`, 413, "file_too_large", { code: "file_too_large" });
|
|
31
|
+
const link = await createLink(sessionId, { direction: "upload", path, executable, ...(extract ? { extract } : {}) });
|
|
32
|
+
const sent = await fetchImpl(link.upload_url, { method: "PUT", body: await openAsBlob(file) }).catch(() => null);
|
|
33
|
+
if (!sent?.ok) throw new CliError("upload failed", `The upload did not go through${sent ? ` (HTTP ${sent.status})` : ""}. Try again.`, sent?.status, "upload_failed", { code: "upload_failed", url: link.url });
|
|
34
|
+
return checkLink(link.url, wait);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Saves a ready download link's file on this machine, replacing the target only once the download is whole. */
|
|
38
|
+
export async function saveFromLink(view, saveTo, { fetchImpl = fetch } = {}) {
|
|
39
|
+
const target = resolve(saveTo), partial = `${target}.nomac-part`;
|
|
40
|
+
const response = await fetchImpl(view.download_url, { redirect: "follow" }).catch(() => null);
|
|
41
|
+
if (!response?.ok || !response.body) throw new CliError("download failed", `The download did not go through${response ? ` (HTTP ${response.status})` : ""}. Try again with get_mac_file_link.`, response?.status, "download_failed", { code: "download_failed" });
|
|
42
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
43
|
+
try {
|
|
44
|
+
await pipeline(Readable.fromWeb(response.body), createWriteStream(partial));
|
|
45
|
+
renameSync(partial, target);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
rmSync(partial, { force: true });
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
return { saved_to: target, saved_bytes: statSync(target).size };
|
|
51
|
+
}
|
package/src/mac-tools.mjs
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { api } from "./api.mjs";
|
|
2
2
|
import { openUrl } from "./mac-credits.mjs";
|
|
3
3
|
import { desktopUrl } from "./desktop.mjs";
|
|
4
|
+
import { readFileSync, statSync, writeFileSync, mkdirSync } from "node:fs";
|
|
5
|
+
import { dirname, resolve } from "node:path";
|
|
6
|
+
import { FETCH_SCRIPT, imageType } from "./mac-fetch.mjs";
|
|
7
|
+
import { checkLink, createLink, saveFromLink, uploadThroughLink } from "./mac-links.mjs";
|
|
8
|
+
const MAX_WRITE_BYTES = 3 * 1024 * 1024;
|
|
4
9
|
const text = value => ({ content: [{ type: "text", text: JSON.stringify(value, null, 2) }] });
|
|
5
10
|
const session = { type: "string", pattern: "^ses_[A-Za-z0-9_-]{1,80}$" };
|
|
6
11
|
const job = { type: "string", pattern: "^job_[a-f0-9]{40}$" };
|
|
@@ -24,7 +29,46 @@ export const MAC_TOOLS = [
|
|
|
24
29
|
return text({ session_id, url, opened_in_browser: opened, message: opened ? "The desktop opened in the human's browser; they may need to sign in to nomac.app." : "Give the human this link; it opens after they sign in to nomac.app." });
|
|
25
30
|
} },
|
|
26
31
|
{ 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))) },
|
|
27
|
-
{ name: "exec_mac", description: "Run any argv command in your cloud Mac workspace.
|
|
32
|
+
{ name: "exec_mac", description: "Run any argv command in your cloud Mac workspace. Waits up to wait_seconds (default 20, max 50): a command that finishes in time returns its exit_code, stdout and stderr (first 64 KiB each, as text) in this one call, as soon as it finishes. For builds, tests and other slow commands pass wait_seconds: 50. A longer one returns its job ID: poll get_mac_job, then read_mac_output. Reuse request_key and identical arguments after lost responses; never rerun to get output. Use bash -lc explicitly for shell syntax. timeout_seconds defaults to 300 and caps at 600; raise it for slow work such as a first simulator boot (about 90 seconds). A command (argv plus env) can be at most 64 KiB: put files on the Mac with write_mac_file (local_path, up to 2 GiB) or fetch_to_mac.", inputSchema: schema({ session_id: session, request_key: key, argv: { type: "array", items: { type: "string" }, minItems: 1 }, timeout_seconds: { type: "integer", minimum: 1, maximum: 600 }, wait_seconds: { type: "integer", minimum: 0, maximum: 50 } }, ["session_id", "request_key", "argv"]), handler: async ({ session_id, request_key, wait_seconds, ...body }) => text(await api("POST", `${base({ session_id })}/jobs?wait=${wait_seconds ?? 20}`, { body, idempotencyKey: request_key })) },
|
|
33
|
+
{ name: "write_mac_file", description: "Write a file on the Mac: local_path uploads a file from this machine, up to 2 GiB, without its content passing through you; or give content as text, or content_base64 for binary, up to 3 MiB. Relative Mac paths are in the session workspace; ~ is the home folder. Creates missing folders and replaces an existing file in one step; set executable for scripts. Safe to repeat. For a whole project use nomac sync; for larger files fetch_to_mac.", inputSchema: schema({ session_id: session, path: { type: "string", minLength: 1, maxLength: 1024 }, local_path: { type: "string" }, content: { type: "string" }, content_base64: { type: "string" }, executable: { type: "boolean" } }, ["session_id", "path"]), handler: async ({ session_id, path, local_path, content, content_base64, executable }) => {
|
|
34
|
+
const given = [local_path, content, content_base64].filter(v => v !== undefined).length;
|
|
35
|
+
if (given !== 1) return text({ error: { code: "invalid_request", message: "Give exactly one of local_path, content or content_base64." } });
|
|
36
|
+
if (local_path !== undefined) {
|
|
37
|
+
const file = resolve(local_path), size = statSync(file).size;
|
|
38
|
+
// Larger files go through an upload link: straight to storage, then onto the Mac.
|
|
39
|
+
if (size > MAX_WRITE_BYTES) return text(await uploadThroughLink(session_id, file, { path, executable: Boolean(executable) }));
|
|
40
|
+
content_base64 = readFileSync(file).toString("base64");
|
|
41
|
+
}
|
|
42
|
+
return text(await api("POST", `${base({ session_id })}/files`, { body: { path, ...(content !== undefined ? { content } : { content_base64 }), executable: Boolean(executable) } }));
|
|
43
|
+
} },
|
|
44
|
+
{ name: "read_mac_file", description: "Read a file on the Mac, up to max_bytes (default 1 MiB, max 4 MiB). save_to writes it to this machine instead of returning it. Otherwise text comes back as text, binary as base64, and PNG, JPEG, GIF and WebP files as an image you can see: for example a Simulator screenshot from `xcrun simctl io booted screenshot shot.png`. Relative Mac paths are in the session workspace. Read-only on the Mac.", inputSchema: schema({ session_id: session, path: { type: "string", minLength: 1, maxLength: 1024 }, max_bytes: { type: "integer", minimum: 1, maximum: 4194304 }, save_to: { type: "string" } }, ["session_id", "path"]), handler: async ({ session_id, path, max_bytes, save_to }) => {
|
|
45
|
+
const file = await api("GET", `${base({ session_id })}/files?${new URLSearchParams({ path, ...(max_bytes ? { max_bytes: String(max_bytes) } : {}) })}`);
|
|
46
|
+
const bytes = file.encoding === "utf8" ? Buffer.from(file.content ?? "", "utf8") : Buffer.from(file.content_base64 ?? "", "base64");
|
|
47
|
+
const meta = Object.fromEntries(Object.entries(file).filter(([k]) => !["content", "content_base64"].includes(k)));
|
|
48
|
+
if (save_to) {
|
|
49
|
+
const target = resolve(save_to); mkdirSync(dirname(target), { recursive: true }); writeFileSync(target, bytes);
|
|
50
|
+
return text({ ...meta, saved_to: target, saved_bytes: bytes.length });
|
|
51
|
+
}
|
|
52
|
+
const mimeType = !file.truncated && file.encoding === "base64" ? imageType(bytes) : undefined;
|
|
53
|
+
if (!mimeType) return text(file);
|
|
54
|
+
return { content: [{ type: "image", data: file.content_base64, mimeType }, { type: "text", text: JSON.stringify({ ...meta, mime_type: mimeType }, null, 2) }] };
|
|
55
|
+
} },
|
|
56
|
+
{ name: "fetch_to_mac", description: "Download a URL straight onto the Mac: any size, as fast as the Mac's connection. path is the file to create, or with extract (zip or tar, including .tar.gz) the folder to unpack into. Relative paths are in the session workspace. Waits up to wait_seconds (default 30, max 50) like exec_mac; a longer download keeps running as a job. For a git repository, run git clone with exec_mac instead.", inputSchema: schema({ session_id: session, request_key: key, url: { type: "string", pattern: "^https?://", maxLength: 4096 }, path: { type: "string", minLength: 1, maxLength: 1024 }, extract: { type: "string", enum: ["zip", "tar"] }, wait_seconds: { type: "integer", minimum: 0, maximum: 50 } }, ["session_id", "request_key", "url", "path"]), handler: async ({ session_id, request_key, url, path, extract, wait_seconds }) => text(await api("POST", `${base({ session_id })}/jobs?wait=${wait_seconds ?? 30}`, { body: { argv: ["/bin/bash", "-c", FETCH_SCRIPT], env: { NOMAC_URL: url, NOMAC_PATH: path, NOMAC_EXTRACT: extract ?? "" }, timeout_seconds: 600 }, idempotencyKey: request_key })) },
|
|
57
|
+
{ name: "create_mac_upload_link", description: "Get a link that puts one file of up to 2 GiB on the Mac at path. With local_path, the file is uploaded from this machine now and placed on the Mac. Otherwise give url to the human (open: true opens it in this machine's browser): they choose a file in the browser and it lands on the Mac, no account needed. Then call get_mac_file_link until state is placed. With extract (zip or tar), path is the folder to unpack into. The link takes one file and expires after expires_in_minutes (default 60, max 1440).", inputSchema: schema({ session_id: session, path: { type: "string", minLength: 1, maxLength: 1024 }, local_path: { type: "string" }, executable: { type: "boolean" }, extract: { type: "string", enum: ["zip", "tar"] }, expires_in_minutes: { type: "integer", minimum: 1, maximum: 1440 }, open: { type: "boolean", default: false, description: "Open the link in this machine's browser for the human." } }, ["session_id", "path"]), handler: async ({ session_id, path, local_path, executable, extract, expires_in_minutes, open }) => {
|
|
58
|
+
if (local_path !== undefined) return text(await uploadThroughLink(session_id, local_path, { path, executable: Boolean(executable), extract }));
|
|
59
|
+
const link = await createLink(session_id, { direction: "upload", path, executable: Boolean(executable), ...(extract ? { extract } : {}), ...(expires_in_minutes ? { expires_in_minutes } : {}) });
|
|
60
|
+
return text({ ...link, opened_in_browser: open ? openUrl(link.url) : false });
|
|
61
|
+
} },
|
|
62
|
+
{ name: "create_mac_download_link", description: "Make a download link for a file or folder on the Mac, up to 2 GiB (a folder downloads as a zip). With save_to, the file is downloaded to this machine as soon as it is ready. Otherwise give url to the human. The link keeps working after the Mac stops, until it expires (expires_in_minutes, default 60, max 1440). Waits up to wait_seconds (default 30) for the copy from the Mac; if still copying, call get_mac_file_link.", inputSchema: schema({ session_id: session, path: { type: "string", minLength: 1, maxLength: 1024 }, save_to: { type: "string" }, expires_in_minutes: { type: "integer", minimum: 1, maximum: 1440 }, wait_seconds: { type: "integer", minimum: 0, maximum: 50 } }, ["session_id", "path"]), handler: async ({ session_id, path, save_to, expires_in_minutes, wait_seconds }) => {
|
|
63
|
+
let link = await createLink(session_id, { direction: "download", path, wait_seconds: wait_seconds ?? 30, ...(expires_in_minutes ? { expires_in_minutes } : {}) });
|
|
64
|
+
if (!save_to) return text(link);
|
|
65
|
+
for (let checks = 0; link.state === "copying" && checks < 12; checks++) link = await checkLink(link.url, 50);
|
|
66
|
+
return text(link.state === "ready" ? { ...link, ...await saveFromLink(link, save_to) } : link);
|
|
67
|
+
} },
|
|
68
|
+
{ name: "get_mac_file_link", description: "Check an upload or download link by its url: waiting_for_upload, placing, placed, copying, ready, failed or expired. An upload that has finished is moved onto the Mac by this call, which waits up to wait_seconds (default 20, max 50) for the move. save_to downloads a ready download link's file to this machine. Safe to repeat.", inputSchema: schema({ link: { type: "string", minLength: 32, maxLength: 2048 }, wait_seconds: { type: "integer", minimum: 0, maximum: 50 }, save_to: { type: "string" } }, ["link"]), handler: async ({ link, wait_seconds, save_to }) => {
|
|
69
|
+
const view = await checkLink(link, wait_seconds ?? 20);
|
|
70
|
+
return text(save_to && view.state === "ready" ? { ...view, ...await saveFromLink(view, save_to) } : view);
|
|
71
|
+
} },
|
|
28
72
|
{ 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)}`)) },
|
|
29
73
|
{ 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" })}`)) },
|
|
30
74
|
];
|
package/src/mcp.mjs
CHANGED
|
@@ -40,11 +40,6 @@ export const TOOLS = [
|
|
|
40
40
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
41
41
|
handler: async () => text(await api("GET", "/api/v1/billing/status")),
|
|
42
42
|
},
|
|
43
|
-
{
|
|
44
|
-
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.",
|
|
45
|
-
inputSchema: { type: "object", properties: { plan: { type: "string", enum: ["starter", "pro"] } }, additionalProperties: false },
|
|
46
|
-
handler: async args => text(await api("POST", "/api/v1/billing/checkout", { body: args })),
|
|
47
|
-
},
|
|
48
43
|
{
|
|
49
44
|
name: "get_refund_status", description: "Read the progress of a refund the account owner requested at nomac.app/usage. Agents cannot request refunds; point the human to that page. Does not submit a refund or cancel anything.",
|
|
50
45
|
inputSchema: { type: "object", properties: { request_id: { type: "string" } }, required: ["request_id"], additionalProperties: false },
|
|
@@ -383,6 +378,20 @@ export const TOOLS = [
|
|
|
383
378
|
},
|
|
384
379
|
];
|
|
385
380
|
|
|
381
|
+
/** Shown to everyone: the Mac tools and support. The rest serve Builder subscribers. */
|
|
382
|
+
const PUBLIC_TOOLS = new Set([...MAC_TOOLS.map(t => t.name), "report_issue"]);
|
|
383
|
+
let subscriber;
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* The Builder (publishing) plans are closed to new subscriptions, so their tools
|
|
387
|
+
* are listed only for accounts that still have one. Hidden tools still answer
|
|
388
|
+
* when called, so existing subscribers' agents keep working.
|
|
389
|
+
*/
|
|
390
|
+
export async function listedTools(isSubscriber = () => (subscriber ??= api("GET", "/api/v1/billing/status").then(s => Boolean(s?.tier) && s.tier !== "none", () => false))) {
|
|
391
|
+
const legacy = await isSubscriber();
|
|
392
|
+
return TOOLS.filter(t => legacy || PUBLIC_TOOLS.has(t.name));
|
|
393
|
+
}
|
|
394
|
+
|
|
386
395
|
export async function runMcpServer() {
|
|
387
396
|
const rl = createInterface({ input: process.stdin, terminal: false });
|
|
388
397
|
const send = (msg) => process.stdout.write(JSON.stringify(msg) + "\n");
|
|
@@ -417,7 +426,7 @@ export async function runMcpServer() {
|
|
|
417
426
|
break;
|
|
418
427
|
case "tools/list":
|
|
419
428
|
reply({
|
|
420
|
-
tools:
|
|
429
|
+
tools: (await listedTools()).map(({ name, description, inputSchema }) => ({ name, description, inputSchema })),
|
|
421
430
|
});
|
|
422
431
|
break;
|
|
423
432
|
case "tools/call": {
|
package/src/session-ssh.mjs
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import {
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { chmodSync, mkdirSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { isAbsolute, join } from "node:path";
|
|
3
6
|
import { fileURLToPath } from "node:url";
|
|
4
7
|
import { CliError } from "./api.mjs";
|
|
5
8
|
|
|
@@ -20,6 +23,23 @@ export function validateWorkspace(path) {
|
|
|
20
23
|
return path;
|
|
21
24
|
}
|
|
22
25
|
|
|
26
|
+
/**
|
|
27
|
+
* A hosted session reuses one SSH connection for 2 minutes on macOS and Linux,
|
|
28
|
+
* so repeated commands skip the tunnel and handshake. The socket is private to
|
|
29
|
+
* this user and named for the session and generation, never shared between
|
|
30
|
+
* Macs. Windows OpenSSH cannot multiplex; other SSH targets keep their own setup.
|
|
31
|
+
*/
|
|
32
|
+
export function multiplexArgs(session, { platform = process.platform, home = homedir() } = {}) {
|
|
33
|
+
const off = ["-o", "ControlMaster=no", "-o", "ControlPath=none"];
|
|
34
|
+
if (session.mode !== "hosted" || platform === "win32") return off;
|
|
35
|
+
const dir = join(home, ".nomac", "cm");
|
|
36
|
+
const name = createHash("sha256").update(`${session.id}:${session.generation ?? 0}`).digest("hex").slice(0, 20);
|
|
37
|
+
const path = join(dir, name);
|
|
38
|
+
if (path.length > 100) return off; // Unix socket paths are limited to about 104 bytes
|
|
39
|
+
try { mkdirSync(dir, { recursive: true, mode: 0o700 }); chmodSync(dir, 0o700); } catch { return off; }
|
|
40
|
+
return ["-o", "ControlMaster=auto", "-o", `ControlPath=${path}`, "-o", "ControlPersist=120"];
|
|
41
|
+
}
|
|
42
|
+
|
|
23
43
|
export function sshArgs(session, { batch = true } = {}) {
|
|
24
44
|
validateTarget(session.target);
|
|
25
45
|
if (session.port !== undefined && (!Number.isInteger(session.port) || session.port < 1 || session.port > 65535)) throw new CliError("invalid SSH port");
|
|
@@ -32,8 +52,7 @@ export function sshArgs(session, { batch = true } = {}) {
|
|
|
32
52
|
"-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
53
|
"-o", "StrictHostKeyChecking=yes", "-o", "ForwardAgent=no",
|
|
34
54
|
"-o", "ConnectTimeout=15", "-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=3",
|
|
35
|
-
|
|
36
|
-
"-o", "ControlMaster=no", "-o", "ControlPath=none",
|
|
55
|
+
...multiplexArgs(session),
|
|
37
56
|
...(batch ? ["-o", "BatchMode=yes", "-o", "ClearAllForwardings=yes"] : []),
|
|
38
57
|
...(session.port ? ["-p", String(session.port)] : []),
|
|
39
58
|
...(session.identity ? ["-i", session.identity, "-o", "IdentitiesOnly=yes"] : []),
|