@nomac/cli 0.11.5 → 0.11.7
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 +4 -4
- package/skill/SKILL.md +31 -10
- package/src/cli.mjs +42 -5
- package/src/hosted-sessions.mjs +107 -29
- package/src/mac-tools.mjs +3 -2
- package/src/secrets.mjs +3 -2
- package/src/session-store.mjs +4 -0
- package/src/session-transfer.mjs +121 -30
- package/src/sessions.mjs +3 -3
- package/src/testflight.mjs +12 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nomac/cli",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.7",
|
|
4
4
|
"description": "Cloud Mac sessions over SSH and HTTPS, plus iOS builds and publishing — built for agents.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -19,11 +19,11 @@
|
|
|
19
19
|
"test": "vitest run"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
+
"https-proxy-agent": "^9.1.0",
|
|
22
23
|
"ignore": "^7.0.0",
|
|
24
|
+
"proxy-from-env": "^2.1.0",
|
|
23
25
|
"tar": "^7.4.0",
|
|
24
|
-
"ws": "^8.21.3"
|
|
25
|
-
"https-proxy-agent": "^9.1.0",
|
|
26
|
-
"proxy-from-env": "^2.1.0"
|
|
26
|
+
"ws": "^8.21.3"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@types/node": "^26.1.1",
|
package/skill/SKILL.md
CHANGED
|
@@ -21,32 +21,47 @@ commands and tools. Mac time is prepaid credit, separate from Builder.
|
|
|
21
21
|
details yourself. Poll credits until the balance appears; a checkout URL is
|
|
22
22
|
not a payment. No Apple Developer account is needed for an unsigned build.
|
|
23
23
|
2. Install `npm install -g @nomac/cli`, run `nomac login`, and give the human
|
|
24
|
-
the printed device URL and code.
|
|
24
|
+
the printed device URL and code. With a key the human made at
|
|
25
|
+
https://nomac.app/keys: `nomac login --with-token < key.txt`, or set
|
|
26
|
+
`NOMAC_API_KEY`. MCP: `claude mcp add nomac -- npx @nomac/cli mcp`.
|
|
25
27
|
Hosted MCP: https://mcp.nomac.app/mcp with `Authorization: Bearer <nmk_key>`.
|
|
26
28
|
3. `nomac start --json`, wait until ready, `nomac sync .`, then `nomac ssh`.
|
|
27
29
|
Run commands with `nomac exec -- COMMAND`: it prints the output and exits
|
|
28
30
|
with the command's code, waiting at most 30 s. For builds and tests use
|
|
29
31
|
`nomac exec --wait 600 -- COMMAND`. Exit code 75 means still running: wait
|
|
30
32
|
with `nomac job JOB_ID --wait 600`, never rerun. In scripts add
|
|
31
|
-
`--request-key stable-key-0001`.
|
|
33
|
+
`--request-key stable-key-0001`. A command may run an hour (`--timeout
|
|
34
|
+
3600`); longer, use `nomac ssh -- COMMAND`. A command ends with everything
|
|
35
|
+
it started, so keep a dev server running with `nomac exec -- nomac-service
|
|
36
|
+
start api -- npx wrangler dev` (`nomac-service logs api`, `stop api`).
|
|
37
|
+
`nomac cancel JOB_ID` stops a command. MCP equivalents are `start_mac`, `exec_mac` (also waits),
|
|
32
38
|
`write_mac_file`, `read_mac_file`, `fetch_to_mac`, `stop_mac`. For a file
|
|
33
39
|
up to 2 GiB from the human, or to hand them a build, use
|
|
34
40
|
`create_mac_upload_link` and `create_mac_download_link`: each returns a url
|
|
35
41
|
they open in a browser.
|
|
36
|
-
4. Download work with `nomac pull ./build
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
42
|
+
4. Download work with `nomac pull ./build`, or to any local path with the
|
|
43
|
+
folder on the Mac named: `nomac pull /tmp/out --from build/export`. Then
|
|
44
|
+
`nomac stop --json` and confirm `cleanup_confirmed`.
|
|
45
|
+
|
|
46
|
+
The CLI keeps the project on the account's drive, in `~/drive/src/<folder
|
|
47
|
+
name>`, which is kept between sessions; stopping deletes everything else on
|
|
48
|
+
the Mac, logs included. Download what you need from outside `~/drive` before
|
|
49
|
+
stopping. SSH disconnects
|
|
40
50
|
do not stop the session. Ready time includes idle time; queue/setup are free.
|
|
41
51
|
One-minute minimum, then by the minute. One session at a time; no overage.
|
|
42
52
|
If start returns `insufficient_credit`, buy credit as in step 1, then retry
|
|
43
53
|
with the same request key.
|
|
44
54
|
Keep the same request key and arguments after uncertain responses; inspect
|
|
45
55
|
the original job before retrying. Output retrieval failure is not job failure.
|
|
46
|
-
|
|
56
|
+
A command runs up to its `timeout_seconds` (300 by default, 3600 at most) and
|
|
57
|
+
says so when that limit stops it; SSH can run longer within session limits.
|
|
47
58
|
CLI sync sends every file (.env and keys included) and symlinks as links, and
|
|
48
59
|
skips dependency and build folders, .git and folders .gitignore lists; a `!`
|
|
49
|
-
line in .nomacignore sends any of them.
|
|
60
|
+
line in .nomacignore sends any of them. A file changed on the Mac since the
|
|
61
|
+
last sync is left alone and named (the rest is sent, and sync exits 1): `nomac
|
|
62
|
+
pull` it, or `nomac sync . --overwrite`. Sync deletes nothing on the Mac
|
|
63
|
+
unless `--delete`, which removes what it sent earlier and you deleted here.
|
|
64
|
+
See the screen with `nomac screenshot` (saves
|
|
50
65
|
`nomac-screenshot.jpg`; `--simulator` for the iOS Simulator) or MCP
|
|
51
66
|
`screenshot_mac`, which returns an image you can look at. Record a video for
|
|
52
67
|
the human with `nomac record`, do the work, then `nomac record stop` (saves the
|
|
@@ -58,7 +73,10 @@ keyboard and mouse, also on the lock screen and in password dialogs, use
|
|
|
58
73
|
and give the human `open_mac_desktop` when they want to see the screen.
|
|
59
74
|
`get_mac_session` (and `nomac status --json`) lists what
|
|
60
75
|
is installed: Ruby with CocoaPods, xcbeautify, xcodegen, gh, Git LFS, jq.
|
|
61
|
-
Pipe xcodebuild through `xcbeautify`.
|
|
76
|
+
Pipe xcodebuild through `xcbeautify`. Simulator devices share names across iOS
|
|
77
|
+
runtimes: pick one by ID (`xcrun simctl list devices available`) or with
|
|
78
|
+
`OS=27.0` in `-destination`. Apple Intelligence cannot generate on a virtual
|
|
79
|
+
Mac, in the Simulator either: test it on a device.
|
|
62
80
|
Report failures using `report_issue` with `session_id`, optional `job_id` and
|
|
63
81
|
a description without secrets. Read https://nomac.app/llms.txt for full limits.
|
|
64
82
|
|
|
@@ -84,7 +102,10 @@ secrets as variables and files, masked in command output; `nomac secrets
|
|
|
84
102
|
install` refreshes a running Mac.
|
|
85
103
|
|
|
86
104
|
The human connects Apple once at https://nomac.app/apple; the App Store
|
|
87
|
-
Connect key stays on NoMac's server.
|
|
105
|
+
Connect key stays on NoMac's server. Check `nomac apple` (MCP
|
|
106
|
+
`connect_status`) before asking them: it shows the key, certificates and
|
|
107
|
+
profiles already there. Then, from your project folder (the CLI acts on the
|
|
108
|
+
Mac):
|
|
88
109
|
|
|
89
110
|
1. `nomac sign [path]` (MCP `set_up_signing`) makes and installs the App Store
|
|
90
111
|
profiles the project needs and prints the files, the reserved build number
|
package/src/cli.mjs
CHANGED
|
@@ -17,6 +17,7 @@ Use it:
|
|
|
17
17
|
nomac exec -- ls run a command; prints its output, keeps its exit code
|
|
18
18
|
nomac exec --wait 600 -- xcodebuild test
|
|
19
19
|
wait up to 10 minutes (the default is 30 seconds)
|
|
20
|
+
nomac cancel JOB_ID stop a running command and everything it started
|
|
20
21
|
nomac ssh open a shell (nomac ssh -- command runs one)
|
|
21
22
|
nomac sync . upload changed project files
|
|
22
23
|
nomac pull ./build download a folder from the Mac
|
|
@@ -31,6 +32,7 @@ Use it:
|
|
|
31
32
|
nomac record record a video; nomac record stop saves it with a share link
|
|
32
33
|
nomac status state, cost so far and when it stops
|
|
33
34
|
nomac secrets variables and files every Mac receives (nomac secrets --help)
|
|
35
|
+
nomac apple whether Apple is connected: team key, certificates, profiles
|
|
34
36
|
nomac sign [path] set up App Store signing for the project on the Mac
|
|
35
37
|
nomac testflight app.ipa send a signed .ipa on the Mac to TestFlight
|
|
36
38
|
nomac publish app.ipa submit it for App Review (--confirm sends it)
|
|
@@ -106,6 +108,7 @@ export async function main(argv) {
|
|
|
106
108
|
case "exec":
|
|
107
109
|
case "job":
|
|
108
110
|
case "output":
|
|
111
|
+
case "cancel":
|
|
109
112
|
case "sync":
|
|
110
113
|
case "ssh":
|
|
111
114
|
case "desktop":
|
|
@@ -150,14 +153,20 @@ export async function main(argv) {
|
|
|
150
153
|
return secretsCommand(rest, { store: sessionStore() });
|
|
151
154
|
}
|
|
152
155
|
case "login": {
|
|
153
|
-
|
|
156
|
+
// A key the person made at nomac.app/keys: as an argument, or on stdin with --with-token
|
|
157
|
+
// (kept out of shell history and the process list).
|
|
158
|
+
const key = rest[0] === "--with-token" ? (await readStdin()).trim() : rest[0];
|
|
154
159
|
if (key) {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
throw new CliError("bad key", "Usage: nomac login nmk_… (or just `nomac login` to approve in the browser)");
|
|
160
|
+
if (!/^nmk_[0-9a-f]{40}$/.test(key)) {
|
|
161
|
+
throw new CliError("bad key", "Usage: nomac login --with-token < key.txt, or nomac login nmk_… (or just `nomac login` to approve in the browser). Keys start with nmk_ and are made at https://nomac.app/keys.");
|
|
158
162
|
}
|
|
163
|
+
// Checked before it replaces the saved one.
|
|
164
|
+
const previous = process.env.NOMAC_API_KEY;
|
|
165
|
+
process.env.NOMAC_API_KEY = key;
|
|
166
|
+
let me;
|
|
167
|
+
try { me = await api("GET", "/api/v1/me"); }
|
|
168
|
+
finally { if (previous === undefined) delete process.env.NOMAC_API_KEY; else process.env.NOMAC_API_KEY = previous; }
|
|
159
169
|
saveApiKey(key);
|
|
160
|
-
const me = await api("GET", "/api/v1/me");
|
|
161
170
|
console.log(`✅ logged in: org "${me.org.name}" (${me.org.id})`);
|
|
162
171
|
return;
|
|
163
172
|
}
|
|
@@ -181,6 +190,13 @@ export async function main(argv) {
|
|
|
181
190
|
const { runMcpServer } = await import("./mcp.mjs");
|
|
182
191
|
return runMcpServer();
|
|
183
192
|
}
|
|
193
|
+
case "apple": {
|
|
194
|
+
if (rest.some(arg => arg !== "--json")) throw new CliError("apple usage", "Usage: nomac apple [--json]\n The account's Apple connection, signing certificates and profiles.");
|
|
195
|
+
const view = await api("GET", "/api/v1/apple");
|
|
196
|
+
if (rest.includes("--json")) { console.log(JSON.stringify(view, null, 2)); return view; }
|
|
197
|
+
console.log(describeApple(view));
|
|
198
|
+
return view;
|
|
199
|
+
}
|
|
184
200
|
case "whoami": {
|
|
185
201
|
const [me, credit] = await Promise.all([api("GET", "/api/v1/me"), api("GET", "/api/v1/mac/credits").catch(() => null)]);
|
|
186
202
|
const o = me.org ?? {}, c = credit?.credits ?? credit ?? {};
|
|
@@ -207,6 +223,27 @@ export async function main(argv) {
|
|
|
207
223
|
}
|
|
208
224
|
}
|
|
209
225
|
|
|
226
|
+
async function readStdin() {
|
|
227
|
+
if (process.stdin.isTTY) throw new CliError("no key on stdin", "Pipe the key in: nomac login --with-token < key.txt");
|
|
228
|
+
let text = "";
|
|
229
|
+
for await (const chunk of process.stdin) { text += chunk; if (text.length > 1024) break; }
|
|
230
|
+
return text;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** What the account has for Apple, for a person or an agent deciding whether to ask anyone. */
|
|
234
|
+
export function describeApple(view) {
|
|
235
|
+
const accounts = view.apple_accounts ?? [], identities = (view.identities ?? []).filter(id => id.state === "active"), profiles = view.profiles ?? [];
|
|
236
|
+
if (!accounts.length && !identities.length) {
|
|
237
|
+
return "Apple: not connected. The account owner connects an App Store Connect team key once at https://nomac.app/apple (only a person can); until then nomac sign and nomac testflight cannot reach Apple.";
|
|
238
|
+
}
|
|
239
|
+
const lines = accounts.map(a => `Apple: connected (App Store Connect key ${a.key_id}, issuer ${a.issuer_id}), ${a.status}, since ${String(a.connected_at).slice(0, 10)}.`);
|
|
240
|
+
if (!accounts.length) lines.push("Apple: no App Store Connect key connected; the certificates below still reach every Mac. Connect one at https://nomac.app/apple to make profiles and send to TestFlight.");
|
|
241
|
+
lines.push(identities.length ? `Certificates on every Mac: ${identities.map(id => `${id.name} (until ${String(id.expires_at).slice(0, 10)})`).join("; ")}.` : "Certificates: none yet; nomac sign makes the App Store one when needed.");
|
|
242
|
+
lines.push(profiles.length ? `Profiles: ${profiles.map(p => `${p.bundle_id} (${p.type})`).join(", ")}.` : "Profiles: none yet; nomac sign makes them for a project.");
|
|
243
|
+
lines.push("Next: nomac sign [path] from your project folder, then nomac testflight App.ipa.");
|
|
244
|
+
return lines.join("\n");
|
|
245
|
+
}
|
|
246
|
+
|
|
210
247
|
async function ensureProject() {
|
|
211
248
|
const state = loadProjectState();
|
|
212
249
|
if (state.project_id) return state;
|
package/src/hosted-sessions.mjs
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { chmodSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { join, resolve } from "node:path";
|
|
3
|
+
import { basename, join, resolve } from "node:path";
|
|
4
4
|
import { parseArgs } from "node:util";
|
|
5
5
|
import { api, CliError } from "./api.mjs";
|
|
6
6
|
import { sessionStore } from "./session-store.mjs";
|
|
7
|
-
import { forwardingArg, isShellLine, runProcess, sshArgs, workspaceCommand } from "./session-ssh.mjs";
|
|
7
|
+
import { forwardingArg, isShellLine, runProcess, shellQuote, sshArgs, validateWorkspace, workspaceCommand } from "./session-ssh.mjs";
|
|
8
8
|
import { describePull, describeSync, pullProject, syncProject } from "./session-transfer.mjs";
|
|
9
9
|
import { macRequest, sessionId, transportOptions } from "./mac-transport.mjs";
|
|
10
10
|
import { copyToClipboard, desktop, DESKTOP_HELP, screenSharing } from "./desktop.mjs";
|
|
11
11
|
import { screenshot, startRecording, stopRecording } from "./mac-screen.mjs";
|
|
12
12
|
|
|
13
|
+
const WORKSPACE = "/Users/agent/workspace";
|
|
14
|
+
|
|
13
15
|
const HELP = `Nomac cloud Mac sessions
|
|
14
16
|
nomac start [--wait 300] [--max-duration 7200] [--idle-timeout 600] [--max-cost 5] [--json]
|
|
15
17
|
[--prompts ask]
|
|
@@ -17,7 +19,12 @@ const HELP = `Nomac cloud Mac sessions
|
|
|
17
19
|
password prompts, its login password in
|
|
18
20
|
$NOMAC_MAC_PASSWORD. --prompts ask keeps macOS's
|
|
19
21
|
usual prompts.
|
|
20
|
-
nomac sync . [--dry-run]
|
|
22
|
+
nomac sync . [--dry-run] [--delete]
|
|
23
|
+
upload changed files to the project folder on the Mac:
|
|
24
|
+
~/drive/src/<this folder's name>, kept between sessions
|
|
25
|
+
(~/workspace with --drive none). A file changed on the Mac
|
|
26
|
+
since is left alone and named (--overwrite replaces it);
|
|
27
|
+
--delete also removes there what you deleted here
|
|
21
28
|
nomac ssh [--forward 8080:3000] full Mac shell and port forwarding
|
|
22
29
|
nomac ssh -- command args run any command over SSH
|
|
23
30
|
nomac desktop [--no-open] [--json] the full macOS desktop in your browser
|
|
@@ -44,7 +51,10 @@ const HELP = `Nomac cloud Mac sessions
|
|
|
44
51
|
(--request-key KEY makes a scripted retry safe)
|
|
45
52
|
nomac job JOB_ID [--wait 600] wait for a command that outlasted --wait
|
|
46
53
|
nomac output JOB_ID [--cursor 0] [--stream stdout|stderr]
|
|
47
|
-
nomac
|
|
54
|
+
nomac cancel JOB_ID stop a running command and everything it started
|
|
55
|
+
nomac pull LOCAL [--from PATH] copy files from the Mac: LOCAL is a folder or file here
|
|
56
|
+
(./out, /tmp/shots, ~/Downloads/App.ipa), PATH is relative
|
|
57
|
+
to the project folder on the Mac (default: LOCAL)
|
|
48
58
|
nomac screenshot [--simulator] [-o FILE]
|
|
49
59
|
save the screen (or the Simulator) as a JPEG,
|
|
50
60
|
nomac-screenshot.jpg unless -o
|
|
@@ -54,20 +64,21 @@ const HELP = `Nomac cloud Mac sessions
|
|
|
54
64
|
nomac status [--json] state, cost so far and when it stops
|
|
55
65
|
nomac attach [SESSION_ID] use the account's running Mac from this folder, when your
|
|
56
66
|
agent started it elsewhere (MCP, the dashboard); start does it too
|
|
57
|
-
nomac stop [--wait 60] [--json] stop the Mac: deletes it and its files
|
|
67
|
+
nomac stop [--wait 60] [--json] stop the Mac: deletes it and its files, not ~/drive
|
|
68
|
+
(your synced project stays there)
|
|
58
69
|
|
|
59
70
|
Login once with nomac login. The CLI creates a local SSH key; it sends only the
|
|
60
71
|
public key. Sessions use the port-443 gateway and verified guest host keys.
|
|
61
72
|
Credit is used at $0.80 an hour, billed by the minute, while the VM is ready,
|
|
62
73
|
including between commands. Sessions run 2 hours by default (--max-duration,
|
|
63
|
-
up to 8 hours) and spend at most $5 (--max-cost). No credit? nomac credits buy. Stop removes the VM and
|
|
74
|
+
up to 8 hours) and spend at most $5 (--max-cost). No credit? nomac credits buy. Stop removes the VM and every file outside ~/drive.
|
|
64
75
|
A wait timeout leaves the session attached: inspect status or run stop.
|
|
65
76
|
Use nomac start --host user@mac to connect an existing Mac instead.`;
|
|
66
77
|
const options = {
|
|
67
78
|
help: { type: "boolean" }, json: { type: "boolean" }, wait: { type: "string" },
|
|
68
79
|
"max-duration": { type: "string" }, "idle-timeout": { type: "string" },
|
|
69
80
|
"max-cost": { type: "string" }, "request-key": { type: "string" }, timeout: { type: "string" },
|
|
70
|
-
overwrite: { type: "boolean" }, "dry-run": { type: "boolean" }, from: { type: "string" },
|
|
81
|
+
overwrite: { type: "boolean" }, "dry-run": { type: "boolean" }, from: { type: "string" }, delete: { type: "boolean" },
|
|
71
82
|
forward: { type: "string", multiple: true }, cursor: { type: "string" }, stream: { type: "string" },
|
|
72
83
|
"no-open": { type: "boolean" }, simulator: { type: "boolean" }, output: { type: "string", short: "o" }, max: { type: "string" },
|
|
73
84
|
prompts: { type: "string" }, gatekeeper: { type: "string" }, copy: { type: "boolean" }, app: { type: "boolean" },
|
|
@@ -171,7 +182,15 @@ const NOTICE_MS = 5000;
|
|
|
171
182
|
* running keeps running on the Mac; the CLI says how to wait for it and exits
|
|
172
183
|
* 75. --wait 0 only starts it.
|
|
173
184
|
*/
|
|
174
|
-
|
|
185
|
+
/** "13:05 UTC", when a running command's time limit stops it. */
|
|
186
|
+
const stopsAt = job => Number.isFinite(job?.deadline) && job.deadline > Date.UTC(2020, 0) ? ` Its time limit (--timeout) stops it at ${new Date(job.deadline).toISOString().slice(11, 16)} UTC.` : "";
|
|
187
|
+
/** Why a command ended without an exit code of its own: its time limit, or a cancel. */
|
|
188
|
+
function endedBy(job, timeoutSeconds) {
|
|
189
|
+
if (job.reason === "timeout") return `${job.id} was stopped by its time limit${timeoutSeconds ? ` (--timeout ${timeoutSeconds})` : " (--timeout)"}, not by an error of its own. Run it again with a longer --timeout, up to 3600 seconds; for a server that should keep running, use nomac-service on the Mac.`;
|
|
190
|
+
if (job.reason === "cancelled") return `${job.id} was cancelled (nomac cancel).`;
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
async function waitForCommand(id, base, started, waitSeconds, { request, transport, cache, print, stdout, stderr, exitWith, sleep, now, json, timeoutSeconds }) {
|
|
175
194
|
let job = started;
|
|
176
195
|
const deadline = now() + waitSeconds * 1000;
|
|
177
196
|
const begun = now();
|
|
@@ -198,10 +217,10 @@ async function waitForCommand(id, base, started, waitSeconds, { request, transpo
|
|
|
198
217
|
const unknown = job.state === "unknown";
|
|
199
218
|
const busyNote = busy ? "The Mac is busy (for example, the iOS Simulator is booting) and answers slowly. " : "";
|
|
200
219
|
const next = unknown ? `The Mac could not confirm whether ${job.id} finished. Inspect it with nomac job ${job.id}; do not rerun it.`
|
|
201
|
-
: `${busyNote}Still running after ${waitSeconds} s; it keeps running on the Mac
|
|
220
|
+
: `${busyNote}Still running after ${waitSeconds} s; it keeps running on the Mac.${stopsAt(job)} Wait for it: nomac job ${job.id} --wait 600. Next time wait up front: nomac exec --wait 600 -- command. Over an hour: nomac ssh -- command. Do not rerun it; to stop it: nomac cancel ${job.id}.`;
|
|
202
221
|
if (json) print(JSON.stringify({ ...job, ...(busy ? { busy: true } : {}), next_step: next }, null, 2));
|
|
203
222
|
else if (unknown) stderr(`nomac: ${next}\n`);
|
|
204
|
-
else stderr(`nomac: ${busy ? "the Mac is busy (for example, the iOS Simulator is booting); " : ""}still running after ${waitSeconds} s (${job.id}); it keeps running on the Mac
|
|
223
|
+
else stderr(`nomac: ${busy ? "the Mac is busy (for example, the iOS Simulator is booting); " : ""}still running after ${waitSeconds} s (${job.id}); it keeps running on the Mac.${stopsAt(job)}\n wait for it: nomac job ${job.id} --wait 600\n stop it: nomac cancel ${job.id}\n next time, wait longer: nomac exec --wait 600 -- command\n over an hour: nomac ssh -- command\n`);
|
|
205
224
|
exitWith(unknown ? 1 : STILL_RUNNING);
|
|
206
225
|
return job;
|
|
207
226
|
}
|
|
@@ -219,9 +238,9 @@ async function waitForCommand(id, base, started, waitSeconds, { request, transpo
|
|
|
219
238
|
return Buffer.concat(parts);
|
|
220
239
|
};
|
|
221
240
|
const [out, err] = await Promise.all([read("stdout"), read("stderr")]);
|
|
222
|
-
const streams = { stdout: out, stderr: err };
|
|
223
|
-
if (json) print(JSON.stringify({ ...job, stdout: streams.stdout.toString("utf8"), stderr: streams.stderr.toString("utf8") }, null, 2));
|
|
224
|
-
else { stdout(streams.stdout); stderr(streams.stderr); }
|
|
241
|
+
const streams = { stdout: out, stderr: err }, ended = endedBy(job, timeoutSeconds);
|
|
242
|
+
if (json) print(JSON.stringify({ ...job, stdout: streams.stdout.toString("utf8"), stderr: streams.stderr.toString("utf8"), ...(ended ? { next_step: ended } : {}) }, null, 2));
|
|
243
|
+
else { stdout(streams.stdout); stderr(streams.stderr); if (ended) stderr(`nomac: ${ended}\n`); }
|
|
225
244
|
exitWith(typeof job.exit_code === "number" ? job.exit_code : 1);
|
|
226
245
|
return job;
|
|
227
246
|
}
|
|
@@ -247,6 +266,12 @@ const uncertainOutcome = error => !Number.isInteger(error?.status) || error.stat
|
|
|
247
266
|
const usd = microunits => `$${(Number(microunits ?? 0) / 1_000_000).toFixed(2)}`;
|
|
248
267
|
const clockUtc = ms => new Date(ms).toISOString().slice(11, 16) + " UTC";
|
|
249
268
|
/** One readable paragraph for nomac status. */
|
|
269
|
+
/** "Project: ~/drive/src/app, on your drive: the next session continues from it." */
|
|
270
|
+
export function projectLine({ path, on_drive }) {
|
|
271
|
+
const short = String(path).replace(/^\/Users\/agent(?=\/)/, "~");
|
|
272
|
+
return on_drive ? `Project: ${short}, on your drive: kept when the Mac stops, and the next session builds only what changed.`
|
|
273
|
+
: `Project: ${short}, deleted when the Mac stops: nomac pull what you need first.`;
|
|
274
|
+
}
|
|
250
275
|
export function statusSummary(session, image) {
|
|
251
276
|
const lines = [`${session.id}: ${session.state}`];
|
|
252
277
|
if (image?.macos) {
|
|
@@ -297,7 +322,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
297
322
|
const usage = billing?.mode === "weekly_included"
|
|
298
323
|
? `Included time used: ${billing.used_seconds} seconds. No extra charge.`
|
|
299
324
|
: `Cost so far: $${(Number(result.session?.cost?.accrued_microunits ?? 0) / 1_000_000).toFixed(2)} ($0.80/hour, billed by the minute while the Mac is ready). Credit left: nomac credits`;
|
|
300
|
-
const next = result.session.state === "ready" ? "Next: nomac sync . → nomac ssh
|
|
325
|
+
const next = result.session.state === "ready" ? `${result.project ? `${projectLine(result.project)}\n` : ""}Next: nomac sync . → nomac ssh`
|
|
301
326
|
: result.session.failure_code === "capacity_unavailable" ? "No Mac was free within 5 minutes; nothing was charged. Run nomac start again in a few minutes."
|
|
302
327
|
: result.session.state === "queued" ? "Waiting for a free Mac (waiting is free). Check again: nomac start"
|
|
303
328
|
: ["stopped", "failed"].includes(result.session.state) ? `${result.session.drive ? "The drive saves in the background (nomac drive shows it). " : ""}Start another: nomac start`
|
|
@@ -319,10 +344,39 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
319
344
|
const access = await sessionAccess(local.id, request);
|
|
320
345
|
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");
|
|
321
346
|
if (local.attached && !local.keyAdded) await addKey(local);
|
|
322
|
-
|
|
347
|
+
// A session an older CLI began in ~/workspace stays there, so nothing moves mid-session.
|
|
348
|
+
if (local.workspace && !local.workspaceDecided) local.workspaceDecided = true;
|
|
349
|
+
if (!local.workspaceDecided) await placeWorkspace(local);
|
|
350
|
+
Object.assign(local, { generation: access.generation, hostKey: access.ssh.host_key, target: `agent@nomac-${local.id}`, workspace: local.workspace ?? WORKSPACE, knownHosts: join(store.directory, "known_hosts") });
|
|
323
351
|
writeFileSync(local.knownHosts, `nomac-${local.id} ${access.ssh.host_key}\n`, { mode: 0o600 }); chmodSync(local.knownHosts, 0o600); store.save(local);
|
|
324
352
|
return local;
|
|
325
353
|
}
|
|
354
|
+
/**
|
|
355
|
+
* With the account's drive, this folder's project lives on it, at ~/drive/src/<folder name>,
|
|
356
|
+
* so the next session finds it and builds only what changed (~/workspace is emptied every
|
|
357
|
+
* session). A name another local folder took gets a suffix; the claims are kept in
|
|
358
|
+
* ~/drive/.nomac/projects. Without a drive, the workspace is ~/workspace as before.
|
|
359
|
+
*/
|
|
360
|
+
async function placeWorkspace(local) {
|
|
361
|
+
if (local.snapshot?.drive?.state === "mounted") {
|
|
362
|
+
const name = (basename(store.project).replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^[.-]+/, "") || "project").slice(0, 64);
|
|
363
|
+
const origin = createHash("sha256").update(store.project).digest("hex").slice(0, 12);
|
|
364
|
+
const script = 'set -e; claims=~/drive/.nomac/projects; mkdir -p "$claims" ~/drive/src; d="$NAME"; '
|
|
365
|
+
// A folder another local folder claimed, or one made on the Mac some other way, is left alone.
|
|
366
|
+
+ 'if { [ -e "$claims/$d" ] && [ "$(cat "$claims/$d")" != "$ORIGIN" ]; } || { [ ! -e "$claims/$d" ] && [ -n "$(ls -A ~/drive/src/"$d" 2>/dev/null)" ]; }; then d="$NAME-$ORIGIN"; fi; '
|
|
367
|
+
+ 'printf %s "$ORIGIN" > "$claims/$d"; mkdir -p ~/drive/src/"$d"; cd ~/drive/src/"$d"; pwd';
|
|
368
|
+
const job = await request("POST", `/api/v1/mac/sessions/${sessionId(local.id)}/jobs?wait=30`, { idempotencyKey: `cli-place-${local.id.slice(4, 24)}-${origin}`,
|
|
369
|
+
body: { argv: ["/bin/bash", "-c", script], env: { NAME: name, ORIGIN: origin }, timeout_seconds: 30 } }).catch(() => null);
|
|
370
|
+
const path = String(job?.stdout ?? "").trim();
|
|
371
|
+
if (job?.exit_code === 0 && path.startsWith("/Users/agent/drive/src/") && !path.slice(23).includes("/")) {
|
|
372
|
+
// The same folder on the drive as last time: what was synced there then is still there.
|
|
373
|
+
if (local.previousDriveWorkspace?.path === path) store.saveBaseline(local.previousDriveWorkspace.baseline);
|
|
374
|
+
Object.assign(local, { workspace: validateWorkspace(path), workspaceOnDrive: true });
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
delete local.previousDriveWorkspace;
|
|
378
|
+
local.workspaceDecided = true; store.save(local);
|
|
379
|
+
}
|
|
326
380
|
/**
|
|
327
381
|
* A Mac started elsewhere (by an agent over MCP, the dashboard or another folder) took no key
|
|
328
382
|
* from this folder: add this folder's own to its authorized keys with an ordinary command, so
|
|
@@ -335,6 +389,10 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
335
389
|
if (job.exit_code !== 0) throw new CliError("Could not add this folder's SSH key to the Mac.", `Adding the key ended with ${job.state === "exited" ? `exit code ${job.exit_code}` : `the command ${job.state}`}. Try again: nomac attach ${local.id}`);
|
|
336
390
|
local.keyAdded = true; store.save(local);
|
|
337
391
|
}
|
|
392
|
+
/** Where this folder's project is on the Mac, once a ready Mac has decided it. */
|
|
393
|
+
const project = local => local.state === "ready" && local.workspaceDecided ? { project: { path: local.workspace, on_drive: Boolean(local.workspaceOnDrive) } } : {};
|
|
394
|
+
/** The last session's folder on the drive and what was synced to it, for the next session to pick up. */
|
|
395
|
+
const previousDrive = old => old?.workspaceOnDrive ? { previousDriveWorkspace: { path: old.workspace, baseline: store.baseline() } } : {};
|
|
338
396
|
/** The account's running Macs, newest first: queued, starting or ready. */
|
|
339
397
|
async function liveSessions() {
|
|
340
398
|
const answer = await request("GET", "/api/v1/mac/sessions");
|
|
@@ -345,7 +403,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
345
403
|
const identity = join(store.directory, `id_ed25519_${randomUUID()}`);
|
|
346
404
|
await run("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-C", "nomac-session", "-f", identity]);
|
|
347
405
|
const local = { mode: "hosted", state: view.state, id: sessionId(view.id), identity, attached: true, outcomeUnknown: false, requestKey: `attach-${view.id}`,
|
|
348
|
-
request: { profile: "standard", limits: view.limits ?? {}, ...(view.access ? { access: view.access } : {}) } };
|
|
406
|
+
request: { profile: "standard", limits: view.limits ?? {}, ...(view.access ? { access: view.access } : {}) }, ...previousDrive(store.load()) };
|
|
349
407
|
store.save(local); store.saveBaseline({});
|
|
350
408
|
const deadline = now() + wait * 1000;
|
|
351
409
|
for (;;) {
|
|
@@ -368,20 +426,36 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
368
426
|
if (command === "status") {
|
|
369
427
|
if (positionals.length) throw new CliError("Usage: nomac status [--json]");
|
|
370
428
|
const loaded = store.load();
|
|
371
|
-
if (!loaded) {
|
|
429
|
+
if (!loaded) {
|
|
430
|
+
// Run from another folder, the account's Mac may well be running: say so rather than "none".
|
|
431
|
+
const live = await liveSessions().catch(() => []);
|
|
432
|
+
if (values.json) print(JSON.stringify({ session: null, ...(live.length ? { running_elsewhere: live[0].id } : {}) }));
|
|
433
|
+
else print(live.length ? `No Mac in this folder, but your account's Mac ${live[0].id} is ${live[0].state}: run nomac in the project folder that started it, or use it from here with nomac attach.` : "No Mac in this folder. Start one: nomac start");
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
372
436
|
const local = localSession(), result = await request("GET", `/api/v1/mac/sessions/${sessionId(local.id)}`);
|
|
373
437
|
const session = saveView(local, result.session);
|
|
374
438
|
// This folder's Mac has stopped, but the account may run another, started elsewhere.
|
|
375
439
|
const elsewhere = ["stopped", "failed"].includes(session.state) ? (await liveSessions().catch(() => [])).find(s => s.id !== session.id) : undefined;
|
|
376
|
-
if (values.json) { print(JSON.stringify({ session, ...(result.image ? { image: result.image } : {}), ...(elsewhere ? { running_elsewhere: elsewhere.id } : {}) }, null, 2)); return session; }
|
|
377
|
-
print(statusSummary(session, result.image) + (elsewhere ? `\nYour account's Mac ${elsewhere.id} is ${elsewhere.state}, started elsewhere: nomac attach` : ""));
|
|
440
|
+
if (values.json) { print(JSON.stringify({ session, ...(result.image ? { image: result.image } : {}), ...project(local), ...(elsewhere ? { running_elsewhere: elsewhere.id } : {}) }, null, 2)); return session; }
|
|
441
|
+
print(statusSummary(session, result.image) + (project(local).project ? `\n${projectLine(project(local).project)}` : "") + (elsewhere ? `\nYour account's Mac ${elsewhere.id} is ${elsewhere.state}, started elsewhere: nomac attach` : ""));
|
|
378
442
|
return session;
|
|
379
443
|
}
|
|
444
|
+
if (command === "cancel") {
|
|
445
|
+
if (positionals.length !== 1 || !/^job_[a-f0-9]{40}$/.test(positionals[0])) throw new CliError("Usage: nomac cancel JOB_ID");
|
|
446
|
+
const local = localSession(), job = await hostedRequest(local.id, "POST", `/api/v1/mac/sessions/${sessionId(local.id)}/jobs/${positionals[0]}/cancel`, {}, { request, transport });
|
|
447
|
+
if (values.json) { print(JSON.stringify(job, null, 2)); return job; }
|
|
448
|
+
const ended = job.exit_code === null || job.exit_code === undefined ? (job.signal ? `signal ${job.signal}` : job.state) : `exit code ${job.exit_code}`;
|
|
449
|
+
print(job.cancelled ? `Cancelled ${job.id}: it and everything it started were stopped (${ended}). Its output so far: nomac output ${job.id}`
|
|
450
|
+
: job.state === "exited" ? `${job.id} had already finished (${job.reason === "cancelled" ? "cancelled" : ended}).` : `${job.id} is ${job.state}.`);
|
|
451
|
+
return job;
|
|
452
|
+
}
|
|
380
453
|
if (["exec", "job", "output"].includes(command)) {
|
|
381
454
|
const local = localSession(), base = `/api/v1/mac/sessions/${sessionId(local.id)}`;
|
|
382
455
|
let path, method = "GET", params = {};
|
|
383
456
|
if (command === "exec") {
|
|
384
457
|
if (!positionals.length || !argv.includes("--")) throw new CliError("Use nomac exec -- command args");
|
|
458
|
+
if (!local.workspaceDecided && !local.workspace) { await refresh(local); if (local.state === "ready") await placeWorkspace(local); }
|
|
385
459
|
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.");
|
|
386
460
|
// A person running a command once needs no key; scripts pass one so a lost response can be retried safely.
|
|
387
461
|
// Without a key, a long wait implies a long command, so the command's own limit follows it unless set.
|
|
@@ -389,7 +463,10 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
389
463
|
// A command may run an hour (the gateway's limit); waiting can cover all of it.
|
|
390
464
|
const waitFor = integer(values.wait, 30, 0, 3600);
|
|
391
465
|
const timeout = integer(values.timeout, values["request-key"] === undefined ? Math.max(300, waitFor) : 600, 1, 3600);
|
|
392
|
-
|
|
466
|
+
const line = isShellLine(positionals), dir = local.workspaceOnDrive ? local.workspace : null;
|
|
467
|
+
const remote = dir ? (line ? ["/bin/bash", "-lc", `cd ${shellQuote(dir)} && ${positionals[0]}`] : ["/bin/bash", "-lc", `cd ${shellQuote(dir)} && exec "$@"`, "nomac", ...positionals])
|
|
468
|
+
: line ? ["/bin/bash", "-lc", positionals[0]] : positionals;
|
|
469
|
+
method = "POST"; path = `${base}/jobs`; params = { requestKey: values["request-key"] ?? `cli-${randomUUID()}`, body: { argv: remote, timeout_seconds: timeout } };
|
|
393
470
|
} else {
|
|
394
471
|
if (positionals.length !== 1 || !/^job_[a-f0-9]{40}$/.test(positionals[0])) throw new CliError("Supply the existing job ID.");
|
|
395
472
|
path = `${base}/jobs/${positionals[0]}`;
|
|
@@ -410,7 +487,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
410
487
|
throw error;
|
|
411
488
|
});
|
|
412
489
|
if (!waiting) return output(result);
|
|
413
|
-
return waitForCommand(local.id, base, result, integer(values.wait, 30, 0, 3600), { request, transport, cache, print, stdout, stderr, exitWith, sleep, now, json: Boolean(values.json) });
|
|
490
|
+
return waitForCommand(local.id, base, result, integer(values.wait, 30, 0, 3600), { request, transport, cache, print, stdout, stderr, exitWith, sleep, now, json: Boolean(values.json), timeoutSeconds: params.body?.timeout_seconds });
|
|
414
491
|
}
|
|
415
492
|
if (command === "screenshot") {
|
|
416
493
|
if (positionals.length) throw new CliError("Usage: nomac screenshot [--simulator] [-o FILE]");
|
|
@@ -511,12 +588,12 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
511
588
|
if (current?.mode === "hosted" && current.id && (!wanted || current.id === wanted) && live.some(s => s.id === current.id)) {
|
|
512
589
|
await refresh(current);
|
|
513
590
|
if (current.state === "ready") await ready(current);
|
|
514
|
-
return output({ session: current.snapshot, attached: `This folder already uses ${current.id}
|
|
591
|
+
return output({ session: current.snapshot, attached: `This folder already uses ${current.id}.`, ...project(current) });
|
|
515
592
|
}
|
|
516
593
|
const target = wanted ? live.find(s => s.id === wanted) : live.length === 1 ? live[0] : undefined;
|
|
517
594
|
if (!target) throw new CliError(wanted ? `No running Mac ${wanted} in this account.` : live.length ? `This account has ${live.length} running Macs: name one, nomac attach ${live.map(s => s.id).join(" | ")}` : "This account has no running Mac. Start one: nomac start");
|
|
518
595
|
const local = await attachTo(target);
|
|
519
|
-
return output({ session: local.snapshot, attached: `Attached to ${local.id}, started elsewhere: ssh, sync, pull and exec work from this folder
|
|
596
|
+
return output({ session: local.snapshot, attached: `Attached to ${local.id}, started elsewhere: ssh, sync, pull and exec work from this folder.`, ...project(local) });
|
|
520
597
|
}
|
|
521
598
|
if (command === "start") {
|
|
522
599
|
if (positionals.length) throw new CliError("Usage: nomac start [--wait SECONDS] [--json]");
|
|
@@ -540,7 +617,8 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
540
617
|
const identity = join(store.directory, `id_ed25519_${randomUUID()}`);
|
|
541
618
|
await run("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-C", "nomac-session", "-f", identity]);
|
|
542
619
|
local = { mode: "hosted", state: "requesting", outcomeUnknown: false, identity, requestKey: values["request-key"] ?? `cli-${randomUUID()}`,
|
|
543
|
-
request: { profile: "standard", public_key: readFileSync(`${identity}.pub`, "utf8").trim(), limits, ...(access ? { access } : {}) }
|
|
620
|
+
request: { profile: "standard", public_key: readFileSync(`${identity}.pub`, "utf8").trim(), limits, ...(access ? { access } : {}) },
|
|
621
|
+
...previousDrive(store.load()) };
|
|
544
622
|
store.save(local); store.saveBaseline({});
|
|
545
623
|
}
|
|
546
624
|
if (!local.id) {
|
|
@@ -554,7 +632,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
554
632
|
const [running] = await liveSessions();
|
|
555
633
|
if (running) {
|
|
556
634
|
local = await attachTo(running);
|
|
557
|
-
return output({ session: local.snapshot, attached: `Your account's Mac ${local.id} was already running (started elsewhere): this folder now uses it
|
|
635
|
+
return output({ session: local.snapshot, attached: `Your account's Mac ${local.id} was already running (started elsewhere): this folder now uses it.`, ...project(local) });
|
|
558
636
|
}
|
|
559
637
|
}
|
|
560
638
|
if (!previouslyUncertain && startRejections.has(error.code) && Number.isInteger(error.status) && error.status >= 400 && error.status <= 503) {
|
|
@@ -573,7 +651,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
573
651
|
await sleep(2000);
|
|
574
652
|
}
|
|
575
653
|
if (local.state === "ready") await ready(local);
|
|
576
|
-
return output({ session: local.snapshot, request_key: local.requestKey, ...(local.warning ? { warning: local.warning } : {}) });
|
|
654
|
+
return output({ session: local.snapshot, request_key: local.requestKey, ...project(local), ...(local.warning ? { warning: local.warning } : {}) });
|
|
577
655
|
}
|
|
578
656
|
const local = localSession();
|
|
579
657
|
if (command === "stop") {
|
|
@@ -586,13 +664,13 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
586
664
|
await ready(local);
|
|
587
665
|
if (command === "sync") {
|
|
588
666
|
if (positionals.length > 1 || (positionals[0] && resolve(positionals[0]) !== store.project)) throw new CliError("Run nomac sync . from the attached project.");
|
|
589
|
-
const result = await syncProject(local, store, { overwrite: values.overwrite, dryRun: values["dry-run"], run });
|
|
667
|
+
const result = await syncProject(local, store, { overwrite: values.overwrite, dryRun: values["dry-run"], deleteMissing: values.delete, run });
|
|
590
668
|
if (!values.json) { print(describeSync(result)); return result; }
|
|
591
669
|
return output(result);
|
|
592
670
|
}
|
|
593
671
|
if (command === "pull") {
|
|
594
|
-
if (positionals.length !== 1) throw new CliError("Usage: nomac pull LOCAL_PATH [--from
|
|
595
|
-
const result = await pullProject(local, store, positionals[0], { from: values.from
|
|
672
|
+
if (positionals.length !== 1) throw new CliError("Usage: nomac pull LOCAL_PATH [--from PATH_ON_THE_MAC]");
|
|
673
|
+
const result = await pullProject(local, store, positionals[0], { from: values.from, overwrite: values.overwrite, dryRun: values["dry-run"], run });
|
|
596
674
|
if (!values.json) { print(describePull(result)); return result; }
|
|
597
675
|
return output(result);
|
|
598
676
|
}
|
package/src/mac-tools.mjs
CHANGED
|
@@ -28,7 +28,7 @@ export const MAC_TOOLS = [
|
|
|
28
28
|
{ name: "get_mac_drive", description: "Read the account's persistent drive: a disk kept between sessions and mounted at ~/drive on every Mac (50 GB). What is there stays for the next session: keep projects in ~/drive/src for incremental builds; package and build caches (DerivedData, Swift packages, CocoaPods, npm) are kept there already. NoMac saves it when a Mac stops, with nothing to call. Shows the space used, the newest saved version, a save still in progress, recent versions, and the date it is deleted after 90 days without a Mac.", inputSchema: schema({}), handler: async () => text(await api("GET", "/api/v1/mac/drive")) },
|
|
29
29
|
{ name: "reset_mac_drive", description: "Empty the account's persistent drive: the next Mac starts with a new, empty ~/drive, and everything on the old drive is deleted, with no way back. Stop any running session first (stop_mac). For a single Mac without the drive, start_mac with access drive none instead.", inputSchema: schema({}), handler: async () => text(await api("DELETE", "/api/v1/mac/drive")) },
|
|
30
30
|
{ name: "list_secrets", description: "List the account's secrets by name: variables (set in every shell and command on the Mac) and files (with their path on the Mac), whether every Mac receives them or only when asked, sizes, and secrets an agent asked for that the person has not entered yet. Values are never returned.", inputSchema: schema({}), handler: async () => text(await api("GET", "/api/v1/secrets")) },
|
|
31
|
-
{ name: "request_secret", description: "Get a link where the person enters a secret in their browser, so its value never passes through this chat: an API token, a .env value, a .p12 certificate or a config file. kind variable is one line of text, set as an environment variable on the Mac; kind file is stored at destination (under the home folder, like ~/.netrc; default ~/.nomac/secrets/<name>). Give the person the
|
|
31
|
+
{ name: "request_secret", description: "Get a link where the person enters a secret in their browser, so its value never passes through this chat: an API token, a .env value, a .p12 certificate or a config file. kind variable is one line of text, set as an environment variable on the Mac; kind file is stored at destination (under the home folder, like ~/.netrc; default ~/.nomac/secrets/<name>). Give the url to the person who holds the value; they need no NoMac account, and it works once, for 24 hours. Then list_secrets shows it, new Macs receive it, and install_secrets puts it on a running Mac. With a shell on this machine, the person can also run nomac secrets set NAME themselves. Never ask for a secret in the chat.", inputSchema: schema({ name: { type: "string", minLength: 1, maxLength: 128 }, kind: { type: "string", enum: ["variable", "file"] }, description: { type: "string", minLength: 1, maxLength: 500, description: "What the person should enter and why; shown to them" }, destination: { type: "string", maxLength: 256 }, delivery: { type: "string", enum: ["every_mac", "on_request"] } }, ["name", "kind", "description"]), handler: async body => text(await api("POST", "/api/v1/secrets/requests", { body })) },
|
|
32
32
|
{ name: "set_up_signing", description: "Set up App Store signing for a project on the Mac, with the App Store Connect key kept on NoMac's server: reads the project at path (its scheme, app and every extension, with entitlements), registers missing bundle IDs, switches on the capabilities Apple's API can, makes the App Store profiles, and installs them with the account's certificate in a keychain on the Mac. The project never changes. Returns a build settings file (.xcconfig) and export options with the exact xcodebuild, Flutter and fastlane commands. Creates a distribution certificate at Apple only when the account has none. Expo and Capacitor apps need their ios folder first. A step only a person can take comes back as needs_human with its link.", inputSchema: schema({ session_id: session, path: { type: "string", minLength: 1, maxLength: 1024, description: "The project folder on the Mac, relative to the workspace" }, scheme: { type: "string", minLength: 1, maxLength: 256 }, container: { type: "string", minLength: 1, maxLength: 1024 }, issuer_id: { type: "string", maxLength: 64 } }, ["session_id"]), handler: async ({ session_id, ...body }) => text(await api("POST", `${base({ session_id })}/signing`, { body })) },
|
|
33
33
|
{ name: "send_to_testflight", description: "Send a signed .ipa on the Mac to TestFlight, with the App Store Connect key kept on NoMac's server: NoMac creates the upload at Apple and the Mac sends only the file. Apple then processes the build, usually for 5 to 15 minutes with no Mac needed, and NoMac sets the app's export-compliance answer and What to Test and puts it on TestFlight for the internal testers. Repeat the call with the returned request_key to follow it: sending, processing, on_testflight or failed. Once on_testflight, publish with the returned project_id and build_id submits it for App Review. Apple asks once per app whether it uses encryption beyond HTTPS and Apple's own: pass the person's answer as uses_non_exempt_encryption, never a guess. A build number Apple already has comes back with the next free one. A step only a person can take, such as creating the app in App Store Connect, comes back as needs_human with its link.", inputSchema: schema({ session_id: session, path: { type: "string", minLength: 1, maxLength: 1024, description: "The signed .ipa on the Mac, relative to the workspace or absolute" }, notes: { type: "string", minLength: 1, maxLength: 4000, description: "What to Test, shown to testers" }, uses_non_exempt_encryption: { type: "boolean", description: "The person's answer, asked once per app: does it use encryption beyond HTTPS and Apple's own?" }, issuer_id: { type: "string", maxLength: 64 }, request_key: { ...key, description: "Repeat with the request_key from the first answer to follow that send" }, wait_seconds: { type: "integer", minimum: 0, maximum: 50, description: "Wait up to this long for progress (50 while following)" } }, ["session_id", "path"]), handler: async ({ session_id, ...body }) => text(await api("POST", `${base({ session_id })}/testflight`, { body })) },
|
|
34
34
|
{ name: "notarize", description: "Notarize a Developer ID-signed Mac app, disk image or installer on the Mac (.app, .dmg, .pkg or .zip), with the App Store Connect key kept on NoMac's server: the Mac zips an .app and sends the file to Apple with temporary credentials for this one submission. Apple checks it, usually for a few minutes, then the Mac staples the ticket so it opens without a warning, even offline. Repeat the call with the returned request_key to follow it: preparing, uploading, in_progress, stapling, then accepted, invalid or rejected with Apple's issues. Sign first with the Developer ID Application certificate (the person uploads it at nomac.app/apple), the hardened runtime and a secure timestamp: codesign --force --options runtime --timestamp --sign \"Developer ID Application: …\". Keep the Mac running until it is stapled.", inputSchema: schema({ session_id: session, path: { type: "string", minLength: 1, maxLength: 1024, description: "The .app, .dmg, .pkg or .zip on the Mac, relative to the workspace or absolute" }, issuer_id: { type: "string", maxLength: 64 }, request_key: { ...key, description: "Repeat with the request_key from the first answer to follow that notarization" }, wait_seconds: { type: "integer", minimum: 0, maximum: 50, description: "Wait up to this long for progress (50 while following)" } }, ["session_id", "path"]), handler: async ({ session_id, ...body }) => text(await api("POST", `${base({ session_id })}/notarize`, { body })) },
|
|
@@ -40,7 +40,7 @@ export const MAC_TOOLS = [
|
|
|
40
40
|
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." });
|
|
41
41
|
} },
|
|
42
42
|
{ 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))) },
|
|
43
|
-
{ 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
|
|
43
|
+
{ 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. cwd runs it in another folder, such as ~/drive/src/app. timeout_seconds defaults to 300 and caps at 3600 (an hour) for builds and test runs; a first simulator boot takes about 90 seconds. cancel_mac_job stops a command. A command ends together with everything it started: keep a dev server running with [\"nomac-service\", \"start\", \"api\", \"--\", \"npx\", \"wrangler\", \"dev\"] (then nomac-service logs api, stop api). A command can be at most 64 KiB in total: 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 }, cwd: { type: "string", minLength: 1, maxLength: 1024, description: "The folder to run in: relative to the workspace, or ~/… and absolute paths anywhere on the Mac" }, timeout_seconds: { type: "integer", minimum: 1, maximum: 3600 }, 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 })) },
|
|
44
44
|
{ 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 }) => {
|
|
45
45
|
const given = [local_path, content, content_base64].filter(v => v !== undefined).length;
|
|
46
46
|
if (given !== 1) return text({ error: { code: "invalid_request", message: "Give exactly one of local_path, content or content_base64." } });
|
|
@@ -115,5 +115,6 @@ export const MAC_TOOLS = [
|
|
|
115
115
|
return text(save_to && view.state === "ready" ? { ...view, ...await saveFromLink(view, save_to) } : view);
|
|
116
116
|
} },
|
|
117
117
|
{ 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)}`)) },
|
|
118
|
+
{ name: "cancel_mac_job", description: "Stop a running command and everything it started (its whole process group): SIGTERM, a second to finish, then SIGKILL. It comes back exited with reason cancelled; its output so far stays readable. A command that already finished is returned unchanged (cancelled: false).", inputSchema: schema({ session_id: session, job_id: job }, ["session_id", "job_id"]), handler: async args => text(await api("POST", `${base(args)}/jobs/${encodeURIComponent(args.job_id)}/cancel`)) },
|
|
118
119
|
{ 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" })}`)) },
|
|
119
120
|
];
|
package/src/secrets.mjs
CHANGED
|
@@ -14,7 +14,8 @@ come back from NoMac, and are never taken as command-line arguments.
|
|
|
14
14
|
nomac secrets rm NAME delete it
|
|
15
15
|
nomac secrets install [NAME...] put them on this folder's Mac now [--session ses_…]
|
|
16
16
|
nomac secrets request NAME --description TEXT [--kind file] [--destination ~/x]
|
|
17
|
-
a link
|
|
17
|
+
a link for whoever holds it to enter it once,
|
|
18
|
+
in the browser, with no NoMac account
|
|
18
19
|
Add --json for JSON.`;
|
|
19
20
|
|
|
20
21
|
/**
|
|
@@ -130,7 +131,7 @@ export async function secretsCommand(args, { api = defaultApi, print = console.l
|
|
|
130
131
|
if (names.length !== 1 || !values.description || !["variable", "file"].includes(kind)) throw new CliError("request usage", "Usage: nomac secrets request NAME --description TEXT [--kind file] [--destination ~/x]");
|
|
131
132
|
const result = await api("POST", "/api/v1/secrets/requests", { body: { name: names[0], kind, description: values.description,
|
|
132
133
|
...(values.destination ? { destination: values.destination } : {}), delivery } });
|
|
133
|
-
return out(result, `
|
|
134
|
+
return out(result, `Send this link to whoever holds ${result.name}; they need no NoMac account. It works once, until ${result.expires_at.slice(0, 16).replace("T", " ")} UTC:\n${result.url}`);
|
|
134
135
|
}
|
|
135
136
|
throw new CliError("unknown secrets command", SECRETS_HELP);
|
|
136
137
|
}
|
package/src/session-store.mjs
CHANGED
|
@@ -13,6 +13,8 @@ export function sessionStore(projectDir = process.cwd(), stateDir = join(homedir
|
|
|
13
13
|
chmodSync(directory, 0o700);
|
|
14
14
|
const stateFile = join(directory, "session.json");
|
|
15
15
|
const baselineFile = join(directory, "baseline.json");
|
|
16
|
+
// What each pull wrote, by local path: such a file, unchanged since, is not a local change.
|
|
17
|
+
const pulledFile = join(directory, "pulled.json");
|
|
16
18
|
const read = (file) => {
|
|
17
19
|
if (!existsSync(file)) return null;
|
|
18
20
|
try { return JSON.parse(readFileSync(file, "utf8")); }
|
|
@@ -31,6 +33,8 @@ export function sessionStore(projectDir = process.cwd(), stateDir = join(homedir
|
|
|
31
33
|
save: (value) => write(stateFile, value),
|
|
32
34
|
baseline: () => read(baselineFile) ?? {},
|
|
33
35
|
saveBaseline: (value) => write(baselineFile, value),
|
|
36
|
+
pulled: () => read(pulledFile) ?? {},
|
|
37
|
+
savePulled: (value) => write(pulledFile, value),
|
|
34
38
|
async locked(operation) {
|
|
35
39
|
const lock = join(directory, "operation.lock");
|
|
36
40
|
try { mkdirSync(lock, { mode: 0o700 }); }
|
package/src/session-transfer.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { constants, copyFileSync, createReadStream, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, chmodSync, utimesSync } from "node:fs";
|
|
3
3
|
import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
5
6
|
import ignoreFactory from "ignore";
|
|
6
7
|
import { CliError } from "./api.mjs";
|
|
@@ -23,6 +24,9 @@ const BUILT_IN = [
|
|
|
23
24
|
{ pattern: ".nomac/", reason: "NoMac's own files" },
|
|
24
25
|
{ pattern: ".nomac.json", reason: "NoMac's own files" },
|
|
25
26
|
{ pattern: ".nomac-transfer/", reason: "NoMac's own files" },
|
|
27
|
+
// nomac screenshot and nomac record save here unless -o says otherwise.
|
|
28
|
+
{ pattern: "nomac-screenshot.jpg", reason: "saved by nomac screenshot" },
|
|
29
|
+
{ pattern: "nomac-recording.mp4", reason: "saved by nomac record" },
|
|
26
30
|
];
|
|
27
31
|
// Built-in patterns are names, matched against the entry's own name, so a folder
|
|
28
32
|
// brought back with `!` is never skipped again through its parent.
|
|
@@ -93,7 +97,7 @@ export async function fingerprint(path) {
|
|
|
93
97
|
}
|
|
94
98
|
|
|
95
99
|
export function safeRelative(path) {
|
|
96
|
-
if (typeof path !== "string" || isAbsolute(path) || path.split(/[\\/]/).includes("..") || /[\x00-\x1f\x7f]/.test(path) || path.includes("\\")) throw new CliError("invalid relative path",
|
|
100
|
+
if (typeof path !== "string" || isAbsolute(path) || path.split(/[\\/]/).includes("..") || /[\x00-\x1f\x7f]/.test(path) || path.includes("\\")) throw new CliError("invalid relative path", `${JSON.stringify(String(path))} is not a path inside the project folder on the Mac: give one relative to it, without '..' (nomac status names the folder). Copy anything from elsewhere on the Mac, such as /tmp, into the project folder first.`);
|
|
97
101
|
return posix.normalize(path || ".").replace(/\/$/, "") || ".";
|
|
98
102
|
}
|
|
99
103
|
|
|
@@ -157,6 +161,46 @@ export async function remoteFingerprints(session, files, options) {
|
|
|
157
161
|
return result;
|
|
158
162
|
}
|
|
159
163
|
|
|
164
|
+
// Removes files sync put on the Mac and the person has since deleted here: never through a
|
|
165
|
+
// linked folder, and empty folders left behind go too (never the project folder itself).
|
|
166
|
+
const REMOTE_DELETE = `
|
|
167
|
+
use strict; use warnings;
|
|
168
|
+
use JSON::PP; use Encode qw(encode_utf8);
|
|
169
|
+
my $json = JSON::PP->new->utf8;
|
|
170
|
+
my $names = $json->decode(do { local $/; <STDIN> });
|
|
171
|
+
die "Invalid file list" unless ref($names) eq 'ARRAY';
|
|
172
|
+
my (@removed, %folders);
|
|
173
|
+
for my $name (@$names) {
|
|
174
|
+
die "Invalid path" if ref($name) || $name =~ m{^/|(?:^|/)\\.\\.(?:/|$)|[\\x00-\\x1f\\x7f\\\\]};
|
|
175
|
+
my $file = './' . encode_utf8($name);
|
|
176
|
+
my @parts = split m{/}, $file;
|
|
177
|
+
pop @parts;
|
|
178
|
+
my $unsafe = 0;
|
|
179
|
+
for (my @up = @parts; @up > 1; pop @up) { if (-l join('/', @up)) { $unsafe = 1; last; } }
|
|
180
|
+
next if $unsafe || !(-l $file || -f $file);
|
|
181
|
+
if (unlink $file) {
|
|
182
|
+
push @removed, $name;
|
|
183
|
+
for (my @up = @parts; @up > 1; pop @up) { $folders{join('/', @up)} = 1; }
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
rmdir $_ for sort { length($b) <=> length($a) } keys %folders;
|
|
187
|
+
print $json->encode(\\@removed);
|
|
188
|
+
`;
|
|
189
|
+
|
|
190
|
+
async function removeOnMac(session, paths, options) {
|
|
191
|
+
const removed = [];
|
|
192
|
+
for (let offset = 0; offset < paths.length; offset += 1000) {
|
|
193
|
+
const batch = paths.slice(offset, offset + 1000);
|
|
194
|
+
for (const path of batch) safeRelative(path);
|
|
195
|
+
const { stdout } = await remote(session, `cd ${shellQuote(validateWorkspace(session.workspace))} && perl -e ${shellQuote(REMOTE_DELETE)}`, { ...options, input: JSON.stringify(batch) });
|
|
196
|
+
let values;
|
|
197
|
+
try { values = JSON.parse(stdout); } catch { throw new CliError("invalid remote delete response"); }
|
|
198
|
+
if (!Array.isArray(values) || values.some(value => !batch.includes(value))) throw new CliError("invalid remote delete response");
|
|
199
|
+
removed.push(...values);
|
|
200
|
+
}
|
|
201
|
+
return removed;
|
|
202
|
+
}
|
|
203
|
+
|
|
160
204
|
function transferArgs() {
|
|
161
205
|
const bridge = fileURLToPath(new URL("../bin/rsync-ssh.mjs", import.meta.url));
|
|
162
206
|
return ["-rpt", "--checksum", "--itemize-changes", "--stats", "--timeout=60", "-e", [process.execPath, bridge].map(rsyncQuote).join(" ")];
|
|
@@ -178,10 +222,12 @@ const megabytes = bytes => `${(bytes / 1_000_000).toFixed(bytes < 10_000_000 ? 1
|
|
|
178
222
|
/** The human summary of a sync: what changed, how much was considered, and what stayed behind. */
|
|
179
223
|
export function describeSync(result) {
|
|
180
224
|
const lines = [`${result.dry_run ? "Would sync" : "Synced"} ${result.changed.length} changed of ${result.files} files (${megabytes(result.bytes)}${result.links ? `, ${result.links} links` : ""}).`];
|
|
225
|
+
if (result.conflicts?.length) lines.push(`Would not send, changed on the Mac since the last sync: ${result.conflicts.slice(0, 10).join(", ")}${result.conflicts.length > 10 ? ` and ${result.conflicts.length - 10} more` : ""}.`);
|
|
226
|
+
if (result.deleted?.length) lines.push(`${result.dry_run ? "Would delete" : "Deleted"} on the Mac, as here: ${result.deleted.slice(0, 10).join(", ")}${result.deleted.length > 10 ? ` and ${result.deleted.length - 10} more` : ""}.`);
|
|
227
|
+
if (result.kept_on_mac?.length) lines.push(`Kept on the Mac, changed there since: ${result.kept_on_mac.slice(0, 10).join(", ")}${result.kept_on_mac.length > 10 ? ` and ${result.kept_on_mac.length - 10} more` : ""}.`);
|
|
181
228
|
if (result.skipped.length) {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
if (result.skipped.length > 20) lines.push(` …and ${result.skipped.length - 20} more (--json lists all)`);
|
|
229
|
+
const shown = result.skipped.slice(0, 6).map(item => `${item.path} (${item.reason})`).join(", ");
|
|
230
|
+
lines.push(`Not sent: ${shown}${result.skipped.length > 6 ? `, and ${result.skipped.length - 6} more (--json lists them)` : ""}. To send one, add a line such as ${result.skipped[0].send_with} to .nomacignore.`);
|
|
185
231
|
}
|
|
186
232
|
return lines.join("\n");
|
|
187
233
|
}
|
|
@@ -198,28 +244,54 @@ function conflictError(paths, side) {
|
|
|
198
244
|
return error;
|
|
199
245
|
}
|
|
200
246
|
|
|
201
|
-
|
|
247
|
+
/** Sync's own refusal: what was sent, and the files left alone because they changed on the Mac. */
|
|
248
|
+
function syncConflictError(paths, sent) {
|
|
249
|
+
const shown = paths.slice(0, 10).join(", ") + (paths.length > 10 ? ` and ${paths.length - 10} more` : "");
|
|
250
|
+
const error = new CliError("transfer conflict", `${sent ? `Sent ${sent} changed ${sent === 1 ? "file" : "files"}. ` : ""}Not sent, because ${paths.length === 1 ? "it" : "they"} changed on the Mac since this folder last synced: ${shown}. Keep the Mac's version: nomac pull ${paths[0]}. Replace it with yours: nomac sync . --overwrite`);
|
|
251
|
+
error.code = "transfer_conflict";
|
|
252
|
+
error.details = { paths, side: "Remote", sent };
|
|
253
|
+
return error;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Sends the project's changed files. A file that changed on the Mac since this folder last
|
|
258
|
+
* synced is left alone (unless --overwrite) and named; everything else is still sent, so one
|
|
259
|
+
* file a tool rewrote there (a lockfile) does not hold back the rest. With --delete, files an
|
|
260
|
+
* earlier sync sent and the person has since deleted here are removed on the Mac too, unless
|
|
261
|
+
* they changed there.
|
|
262
|
+
*/
|
|
263
|
+
export async function syncProject(session, store, { overwrite = false, dryRun = false, deleteMissing = false, run = runProcess } = {}) {
|
|
202
264
|
const selection = projectFiles(store.project);
|
|
203
265
|
const paths = [...selection.files, ...selection.links].sort();
|
|
204
|
-
|
|
266
|
+
const baseline = Object.assign(Object.create(null), store.baseline());
|
|
267
|
+
const exists = path => lstatSync(join(store.project, path), { throwIfNoEntry: false }) !== undefined;
|
|
268
|
+
const gone = deleteMissing ? Object.keys(baseline).filter(path => { try { safeRelative(path); } catch { return false; } return !exists(path); }).sort() : [];
|
|
269
|
+
if (!paths.length && !gone.length) throw new CliError("no project files", "Nothing to send: every file is in a skipped folder. Add `!` lines to .nomacignore for what the Mac needs.");
|
|
205
270
|
const local = Object.create(null);
|
|
206
271
|
for (const path of paths) local[path] = await fingerprint(join(store.project, path));
|
|
207
|
-
const
|
|
208
|
-
const previous = await remoteFingerprints(session, paths, { run });
|
|
272
|
+
const previous = paths.length ? await remoteFingerprints(session, paths, { run }) : {};
|
|
209
273
|
const unsafe = paths.filter(path => ["symlink", "other"].includes(previous[path]));
|
|
210
274
|
if (unsafe.length) throw new CliError("unsafe remote path", `Remote paths are below a symlinked folder or have incompatible types: ${unsafe.slice(0, 10).join(", ")}. Resolve them inside the workspace first.`);
|
|
211
|
-
const conflicts = paths.filter(path => previous[path] !== "missing" && previous[path] !== local[path] && previous[path] !== baseline[path]);
|
|
212
|
-
|
|
213
|
-
const changed =
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
275
|
+
const conflicts = overwrite ? [] : paths.filter(path => previous[path] !== "missing" && previous[path] !== local[path] && previous[path] !== baseline[path]);
|
|
276
|
+
const held = new Set(conflicts), send = paths.filter(path => !held.has(path));
|
|
277
|
+
const changed = send.filter(path => previous[path] !== local[path]), summary = summarize(store.project, selection);
|
|
278
|
+
const onMac = gone.length ? await remoteFingerprints(session, gone, { run }) : {};
|
|
279
|
+
const removable = gone.filter(path => onMac[path] === baseline[path]), keptOnMac = gone.filter(path => !["missing", baseline[path]].includes(onMac[path]));
|
|
280
|
+
if (dryRun) return { dry_run: true, files: paths.length, changed, ...(conflicts.length ? { conflicts } : {}), ...(deleteMissing ? { deleted: removable, kept_on_mac: keptOnMac } : {}), ...summary };
|
|
281
|
+
let output = "";
|
|
282
|
+
if (send.length) {
|
|
283
|
+
// --links sends a symlink as a link: its target is never read or followed.
|
|
284
|
+
output = (await run("rsync", [...transferArgs(), "--links", "--from0", "--files-from=-", `${store.project}/`, `${session.target}:./`], { input: Buffer.from(send.join("\0") + "\0"), env: transferEnv(session) })).stdout;
|
|
285
|
+
}
|
|
286
|
+
const after = send.length ? await remoteFingerprints(session, send, { run }) : {};
|
|
287
|
+
const changedDuringTransfer = send.filter(path => after[path] !== local[path]);
|
|
288
|
+
for (const path of send) if (after[path] === local[path]) baseline[path] = local[path];
|
|
289
|
+
const deleted = removable.length ? await removeOnMac(session, removable, { run }) : [];
|
|
290
|
+
for (const path of [...deleted, ...gone.filter(path => onMac[path] === "missing")]) delete baseline[path];
|
|
220
291
|
store.saveBaseline(baseline);
|
|
221
292
|
if (changedDuringTransfer.length) throw new CliError("transfer changed during verification", "Some files changed during transfer. Stop concurrent edits and rerun sync; verified files were recorded.");
|
|
222
|
-
|
|
293
|
+
if (conflicts.length) throw syncConflictError(conflicts, changed.length);
|
|
294
|
+
return { files: paths.length, changed, ...(deleteMissing ? { deleted, kept_on_mac: keptOnMac } : {}), ...summary, output };
|
|
223
295
|
}
|
|
224
296
|
|
|
225
297
|
/** Refuse symlink ancestors, including the destination itself, before local writes. */
|
|
@@ -234,12 +306,26 @@ export function assertSafeDestination(root, target) {
|
|
|
234
306
|
}
|
|
235
307
|
}
|
|
236
308
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
309
|
+
/**
|
|
310
|
+
* Where a pull writes on this computer: anywhere the person names. A relative path is in the
|
|
311
|
+
* project folder (where the CLI runs); an absolute or ~ path may be outside it, such as /tmp.
|
|
312
|
+
*/
|
|
313
|
+
export function localDestination(store, destination) {
|
|
314
|
+
if (typeof destination !== "string" || !destination || /[\x00-\x1f\x7f]/.test(destination)) throw new CliError("invalid local path", "Give the local folder or file to write, such as ./out, /tmp/shots or ~/Downloads/App.ipa.");
|
|
315
|
+
const expanded = destination === "~" ? homedir() : destination.startsWith("~/") ? join(homedir(), destination.slice(2)) : destination;
|
|
316
|
+
const requested = resolve(store.project, expanded), rel = relative(store.project, requested);
|
|
317
|
+
const inProject = rel === "" || !(rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel));
|
|
318
|
+
return { requested, inProject, projectPath: inProject ? (rel.split(sep).join("/") || ".") : null };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export async function pullProject(session, store, destination, { from, overwrite = false, dryRun = false, run = runProcess } = {}) {
|
|
322
|
+
const { requested, inProject, projectPath } = localDestination(store, destination);
|
|
323
|
+
// A folder in the project comes from the same folder on the Mac unless --from says otherwise.
|
|
324
|
+
if (from === undefined && !inProject) throw new CliError("pull usage", `Name what to copy from the Mac: nomac pull ${destination} --from PATH, with PATH relative to the project folder there.`);
|
|
325
|
+
from = safeRelative(from ?? projectPath);
|
|
326
|
+
// Local safety first: a symlinked destination inside the project is refused before anything
|
|
327
|
+
// is asked of the Mac (outside it, the path the person named is theirs to choose).
|
|
328
|
+
if (inProject) assertSafeDestination(store.project, requested);
|
|
243
329
|
// rsync reaches its source by cd'ing into it, so a single file has to be fetched
|
|
244
330
|
// from its parent directory. Probe the remote path: pulling one file (a
|
|
245
331
|
// screenshot, an .ipa) used to fail with a bare rsync "not a directory".
|
|
@@ -270,23 +356,27 @@ export async function pullProject(session, store, destination, { from = destinat
|
|
|
270
356
|
}
|
|
271
357
|
};
|
|
272
358
|
walk(staging);
|
|
273
|
-
const baseline = Object.assign(Object.create(null), store.baseline()), plan = [], conflicts = [];
|
|
359
|
+
const baseline = Object.assign(Object.create(null), store.baseline()), pulled = Object.assign(Object.create(null), store.pulled()), plan = [], conflicts = [];
|
|
360
|
+
// Writes stay inside the folder pulled into, never through a link in it.
|
|
361
|
+
const safeRoot = inProject ? store.project : localRoot;
|
|
274
362
|
for (const path of files) {
|
|
275
363
|
const target = join(localRoot, rename ?? path), incoming = await fingerprint(join(staging, path));
|
|
276
|
-
assertSafeDestination(
|
|
364
|
+
assertSafeDestination(safeRoot, target);
|
|
277
365
|
const current = existsSync(target) ? await fingerprint(target) : "missing";
|
|
278
366
|
if (current === "other") throw new CliError("incompatible local path", `A directory or special file occupies ${target}.`);
|
|
279
367
|
const remotePath = source === "." ? path : `${source.replace(/\/$/, "")}/${path}`;
|
|
280
368
|
// A baseline is meaningful only when downloading to the original project-relative path.
|
|
281
369
|
const samePath = relative(store.project, target).split(sep).join("/") === remotePath.split(sep).join("/");
|
|
282
|
-
|
|
283
|
-
|
|
370
|
+
// A local file is the person's change unless sync or an earlier pull left it exactly so.
|
|
371
|
+
const known = (samePath && baseline[remotePath] === current) || pulled[target] === current;
|
|
372
|
+
if (current !== "missing" && current !== incoming && !known) conflicts.push(path);
|
|
373
|
+
plan.push({ path, target, current, incoming, remotePath, samePath: inProject && samePath });
|
|
284
374
|
}
|
|
285
375
|
if (conflicts.length && !overwrite) throw conflictError(conflicts, "Local");
|
|
286
376
|
if (dryRun) return { dry_run: true, files: files.length, changed: plan.filter(file => file.current !== file.incoming).map(file => file.path) };
|
|
287
377
|
let backups = false;
|
|
288
378
|
for (const file of plan) {
|
|
289
|
-
assertSafeDestination(
|
|
379
|
+
assertSafeDestination(safeRoot, file.target);
|
|
290
380
|
const current = existsSync(file.target) ? await fingerprint(file.target) : "missing";
|
|
291
381
|
if (current !== file.current) throw new CliError("local file changed", "A local file changed during pull. Stop concurrent edits and retry. Earlier replacements have recovery copies.");
|
|
292
382
|
if (current !== file.incoming) {
|
|
@@ -307,8 +397,9 @@ export async function pullProject(session, store, destination, { from = destinat
|
|
|
307
397
|
} finally { rmSync(temporary, { force: true }); }
|
|
308
398
|
}
|
|
309
399
|
if (file.samePath) baseline[file.remotePath] = file.incoming;
|
|
400
|
+
pulled[file.target] = file.incoming;
|
|
310
401
|
}
|
|
311
|
-
store.saveBaseline(baseline);
|
|
402
|
+
store.saveBaseline(baseline); store.savePulled(pulled);
|
|
312
403
|
return { files: files.length, changed: plan.filter(file => file.current !== file.incoming).map(file => file.path), ...(backups ? { recovery_directory: backup } : {}) };
|
|
313
404
|
} finally { rmSync(staging, { recursive: true, force: true }); }
|
|
314
405
|
}
|
package/src/sessions.mjs
CHANGED
|
@@ -42,7 +42,7 @@ existing connections and remote processes continue. Manage rentals with their ow
|
|
|
42
42
|
const commonOptions = { help: { type: "boolean" }, json: { type: "boolean" } };
|
|
43
43
|
const commandOptions = {
|
|
44
44
|
start: { host: { type: "string" }, identity: { type: "string" }, port: { type: "string" } },
|
|
45
|
-
sync: { overwrite: { type: "boolean" }, "dry-run": { type: "boolean" } },
|
|
45
|
+
sync: { overwrite: { type: "boolean" }, "dry-run": { type: "boolean" }, delete: { type: "boolean" } },
|
|
46
46
|
pull: { overwrite: { type: "boolean" }, "dry-run": { type: "boolean" }, from: { type: "string" } },
|
|
47
47
|
ssh: { forward: { type: "string", multiple: true } },
|
|
48
48
|
stop: {}, session: {},
|
|
@@ -131,12 +131,12 @@ export async function sessionCommand(command, argv, { store: suppliedStore, run
|
|
|
131
131
|
const session = requireReady(store);
|
|
132
132
|
if (command === "sync") {
|
|
133
133
|
if (positionals.length > 1 || (positionals[0] && realpathSync(resolve(positionals[0])) !== store.project)) throw new CliError("sync project mismatch", "Run nomac sync . from the project used by nomac start.");
|
|
134
|
-
const result = await syncProject(session, store, { overwrite: values.overwrite, dryRun: values["dry-run"], run });
|
|
134
|
+
const result = await syncProject(session, store, { overwrite: values.overwrite, dryRun: values["dry-run"], deleteMissing: values.delete, run });
|
|
135
135
|
return output(result, describeSync(result));
|
|
136
136
|
}
|
|
137
137
|
if (command === "pull") {
|
|
138
138
|
if (positionals.length !== 1) throw new CliError("pull usage", "Usage: nomac pull LOCAL_DIRECTORY [--from REMOTE_DIRECTORY] [--dry-run] [--overwrite]");
|
|
139
|
-
const result = await pullProject(session, store, positionals[0], { from: values.from
|
|
139
|
+
const result = await pullProject(session, store, positionals[0], { from: values.from, overwrite: values.overwrite, dryRun: values["dry-run"], run });
|
|
140
140
|
return output(result, describePull(result));
|
|
141
141
|
}
|
|
142
142
|
throw new CliError("unknown session command");
|
package/src/testflight.mjs
CHANGED
|
@@ -17,11 +17,16 @@ needed, and NoMac puts it on TestFlight for your internal testers.
|
|
|
17
17
|
const DONE = new Set(["on_testflight", "failed"]);
|
|
18
18
|
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
/** The line for a view; minutes so far for a send still going, so a slow upload is told from a hang. */
|
|
21
|
+
export function describe(view, minutes = 0) {
|
|
21
22
|
const app = `${view.app.bundle_id} ${view.app.version} (${view.app.build_number})`;
|
|
22
23
|
switch (view.state) {
|
|
23
|
-
case "sending": return `Sending ${app} from the Mac to Apple
|
|
24
|
-
case "processing":
|
|
24
|
+
case "sending": return minutes ? `Still sending ${app} to Apple (${minutes} min so far)…` : `Sending ${app} from the Mac to Apple… Stopping this wait does not stop it: follow it any time with nomac testflight --id ${view.id}`;
|
|
25
|
+
case "processing":
|
|
26
|
+
// Apple is done; a person must act before testers get it. NoMac continues by itself after.
|
|
27
|
+
if (view.needs_human) return `Apple has processed ${app}. Before testers get it, a person must act:\n ${view.needs_human.message}\n ${view.needs_human.url}\n NoMac puts it on TestFlight by itself once that is done; check with nomac testflight --id ${view.id}`;
|
|
28
|
+
if (view.apple_processed) return `Apple has processed ${app}; NoMac is putting it on TestFlight.${view.waiting_for ? `\n Waiting for: ${view.waiting_for}` : ""}\n Stopping this wait does not stop it: follow it later with nomac testflight --id ${view.id}`;
|
|
29
|
+
return `Apple has ${app} and is processing it (usually 5 to 15 minutes; no Mac needed).${view.waiting_for ? `\n Waiting for: ${view.waiting_for}` : ""}\n Stopping this wait does not stop it: follow it later with nomac testflight --id ${view.id}`;
|
|
25
30
|
case "on_testflight": return `On TestFlight: ${app}. Internal testers can install it from the TestFlight app.\n ${view.url}\n ${view.app.platform === "IOS"
|
|
26
31
|
? `To submit it for App Review: nomac publish --project ${view.project_id} --build ${view.build_id}` : "Submit it for App Review in App Store Connect."}`;
|
|
27
32
|
// With a build_id, Apple had the file and refused it while processing; without one, it never reached Apple.
|
|
@@ -63,15 +68,17 @@ export async function testflightCommand(args, { api = defaultApi, print = consol
|
|
|
63
68
|
}
|
|
64
69
|
}
|
|
65
70
|
let last = "";
|
|
71
|
+
const began = Date.now();
|
|
66
72
|
const show = (current) => {
|
|
67
73
|
if (values.json) return;
|
|
68
|
-
const line = describe(current);
|
|
74
|
+
const line = describe(current, current.state === "sending" ? Math.floor((Date.now() - began) / 60_000) : 0);
|
|
69
75
|
if (line !== last) print(line);
|
|
70
76
|
last = line;
|
|
71
77
|
};
|
|
72
78
|
show(view);
|
|
73
79
|
if (!values["no-wait"]) {
|
|
74
|
-
|
|
80
|
+
// A step only a person can take ends the wait: it is said, and NoMac goes on by itself after it.
|
|
81
|
+
while (!DONE.has(view.state) && !view.needs_human) {
|
|
75
82
|
view = await api("GET", `/api/v1/testflight/${encodeURIComponent(view.id)}?wait_seconds=50`);
|
|
76
83
|
show(view);
|
|
77
84
|
}
|