@nomac/cli 0.11.5 → 0.11.6
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/src/cli.mjs +1 -0
- package/src/hosted-sessions.mjs +77 -18
- package/src/mac-tools.mjs +3 -2
- package/src/secrets.mjs +3 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nomac/cli",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.6",
|
|
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/src/cli.mjs
CHANGED
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,9 @@ 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] upload changed files
|
|
22
|
+
nomac sync . [--dry-run] upload changed files to the project folder on the Mac:
|
|
23
|
+
~/drive/src/<this folder's name>, kept between sessions
|
|
24
|
+
(~/workspace with --drive none)
|
|
21
25
|
nomac ssh [--forward 8080:3000] full Mac shell and port forwarding
|
|
22
26
|
nomac ssh -- command args run any command over SSH
|
|
23
27
|
nomac desktop [--no-open] [--json] the full macOS desktop in your browser
|
|
@@ -44,6 +48,7 @@ const HELP = `Nomac cloud Mac sessions
|
|
|
44
48
|
(--request-key KEY makes a scripted retry safe)
|
|
45
49
|
nomac job JOB_ID [--wait 600] wait for a command that outlasted --wait
|
|
46
50
|
nomac output JOB_ID [--cursor 0] [--stream stdout|stderr]
|
|
51
|
+
nomac cancel JOB_ID stop a running command and everything it started
|
|
47
52
|
nomac pull ./output [--from PATH] retrieve files before stopping
|
|
48
53
|
nomac screenshot [--simulator] [-o FILE]
|
|
49
54
|
save the screen (or the Simulator) as a JPEG,
|
|
@@ -54,13 +59,14 @@ const HELP = `Nomac cloud Mac sessions
|
|
|
54
59
|
nomac status [--json] state, cost so far and when it stops
|
|
55
60
|
nomac attach [SESSION_ID] use the account's running Mac from this folder, when your
|
|
56
61
|
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
|
|
62
|
+
nomac stop [--wait 60] [--json] stop the Mac: deletes it and its files, not ~/drive
|
|
63
|
+
(your synced project stays there)
|
|
58
64
|
|
|
59
65
|
Login once with nomac login. The CLI creates a local SSH key; it sends only the
|
|
60
66
|
public key. Sessions use the port-443 gateway and verified guest host keys.
|
|
61
67
|
Credit is used at $0.80 an hour, billed by the minute, while the VM is ready,
|
|
62
68
|
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
|
|
69
|
+
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
70
|
A wait timeout leaves the session attached: inspect status or run stop.
|
|
65
71
|
Use nomac start --host user@mac to connect an existing Mac instead.`;
|
|
66
72
|
const options = {
|
|
@@ -198,10 +204,10 @@ async function waitForCommand(id, base, started, waitSeconds, { request, transpo
|
|
|
198
204
|
const unknown = job.state === "unknown";
|
|
199
205
|
const busyNote = busy ? "The Mac is busy (for example, the iOS Simulator is booting) and answers slowly. " : "";
|
|
200
206
|
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. 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.`;
|
|
207
|
+
: `${busyNote}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 an hour: nomac ssh -- command. Do not rerun it; to stop it: nomac cancel ${job.id}.`;
|
|
202
208
|
if (json) print(JSON.stringify({ ...job, ...(busy ? { busy: true } : {}), next_step: next }, null, 2));
|
|
203
209
|
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.\n wait for it: nomac job ${job.id} --wait 600\n next time, wait longer: nomac exec --wait 600 -- command\n over an hour: nomac ssh -- command\n`);
|
|
210
|
+
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.\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
211
|
exitWith(unknown ? 1 : STILL_RUNNING);
|
|
206
212
|
return job;
|
|
207
213
|
}
|
|
@@ -247,6 +253,12 @@ const uncertainOutcome = error => !Number.isInteger(error?.status) || error.stat
|
|
|
247
253
|
const usd = microunits => `$${(Number(microunits ?? 0) / 1_000_000).toFixed(2)}`;
|
|
248
254
|
const clockUtc = ms => new Date(ms).toISOString().slice(11, 16) + " UTC";
|
|
249
255
|
/** One readable paragraph for nomac status. */
|
|
256
|
+
/** "Project: ~/drive/src/app, on your drive: the next session continues from it." */
|
|
257
|
+
export function projectLine({ path, on_drive }) {
|
|
258
|
+
const short = String(path).replace(/^\/Users\/agent(?=\/)/, "~");
|
|
259
|
+
return on_drive ? `Project: ${short}, on your drive: kept when the Mac stops, and the next session builds only what changed.`
|
|
260
|
+
: `Project: ${short}, deleted when the Mac stops: nomac pull what you need first.`;
|
|
261
|
+
}
|
|
250
262
|
export function statusSummary(session, image) {
|
|
251
263
|
const lines = [`${session.id}: ${session.state}`];
|
|
252
264
|
if (image?.macos) {
|
|
@@ -297,7 +309,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
297
309
|
const usage = billing?.mode === "weekly_included"
|
|
298
310
|
? `Included time used: ${billing.used_seconds} seconds. No extra charge.`
|
|
299
311
|
: `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
|
|
312
|
+
const next = result.session.state === "ready" ? `${result.project ? `${projectLine(result.project)}\n` : ""}Next: nomac sync . → nomac ssh`
|
|
301
313
|
: 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
314
|
: result.session.state === "queued" ? "Waiting for a free Mac (waiting is free). Check again: nomac start"
|
|
303
315
|
: ["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 +331,39 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
319
331
|
const access = await sessionAccess(local.id, request);
|
|
320
332
|
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
333
|
if (local.attached && !local.keyAdded) await addKey(local);
|
|
322
|
-
|
|
334
|
+
// A session an older CLI began in ~/workspace stays there, so nothing moves mid-session.
|
|
335
|
+
if (local.workspace && !local.workspaceDecided) local.workspaceDecided = true;
|
|
336
|
+
if (!local.workspaceDecided) await placeWorkspace(local);
|
|
337
|
+
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
338
|
writeFileSync(local.knownHosts, `nomac-${local.id} ${access.ssh.host_key}\n`, { mode: 0o600 }); chmodSync(local.knownHosts, 0o600); store.save(local);
|
|
324
339
|
return local;
|
|
325
340
|
}
|
|
341
|
+
/**
|
|
342
|
+
* With the account's drive, this folder's project lives on it, at ~/drive/src/<folder name>,
|
|
343
|
+
* so the next session finds it and builds only what changed (~/workspace is emptied every
|
|
344
|
+
* session). A name another local folder took gets a suffix; the claims are kept in
|
|
345
|
+
* ~/drive/.nomac/projects. Without a drive, the workspace is ~/workspace as before.
|
|
346
|
+
*/
|
|
347
|
+
async function placeWorkspace(local) {
|
|
348
|
+
if (local.snapshot?.drive?.state === "mounted") {
|
|
349
|
+
const name = (basename(store.project).replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^[.-]+/, "") || "project").slice(0, 64);
|
|
350
|
+
const origin = createHash("sha256").update(store.project).digest("hex").slice(0, 12);
|
|
351
|
+
const script = 'set -e; claims=~/drive/.nomac/projects; mkdir -p "$claims" ~/drive/src; d="$NAME"; '
|
|
352
|
+
// A folder another local folder claimed, or one made on the Mac some other way, is left alone.
|
|
353
|
+
+ '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; '
|
|
354
|
+
+ 'printf %s "$ORIGIN" > "$claims/$d"; mkdir -p ~/drive/src/"$d"; cd ~/drive/src/"$d"; pwd';
|
|
355
|
+
const job = await request("POST", `/api/v1/mac/sessions/${sessionId(local.id)}/jobs?wait=30`, { idempotencyKey: `cli-place-${local.id.slice(4, 24)}-${origin}`,
|
|
356
|
+
body: { argv: ["/bin/bash", "-c", script], env: { NAME: name, ORIGIN: origin }, timeout_seconds: 30 } }).catch(() => null);
|
|
357
|
+
const path = String(job?.stdout ?? "").trim();
|
|
358
|
+
if (job?.exit_code === 0 && path.startsWith("/Users/agent/drive/src/") && !path.slice(23).includes("/")) {
|
|
359
|
+
// The same folder on the drive as last time: what was synced there then is still there.
|
|
360
|
+
if (local.previousDriveWorkspace?.path === path) store.saveBaseline(local.previousDriveWorkspace.baseline);
|
|
361
|
+
Object.assign(local, { workspace: validateWorkspace(path), workspaceOnDrive: true });
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
delete local.previousDriveWorkspace;
|
|
365
|
+
local.workspaceDecided = true; store.save(local);
|
|
366
|
+
}
|
|
326
367
|
/**
|
|
327
368
|
* A Mac started elsewhere (by an agent over MCP, the dashboard or another folder) took no key
|
|
328
369
|
* from this folder: add this folder's own to its authorized keys with an ordinary command, so
|
|
@@ -335,6 +376,10 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
335
376
|
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
377
|
local.keyAdded = true; store.save(local);
|
|
337
378
|
}
|
|
379
|
+
/** Where this folder's project is on the Mac, once a ready Mac has decided it. */
|
|
380
|
+
const project = local => local.state === "ready" && local.workspaceDecided ? { project: { path: local.workspace, on_drive: Boolean(local.workspaceOnDrive) } } : {};
|
|
381
|
+
/** The last session's folder on the drive and what was synced to it, for the next session to pick up. */
|
|
382
|
+
const previousDrive = old => old?.workspaceOnDrive ? { previousDriveWorkspace: { path: old.workspace, baseline: store.baseline() } } : {};
|
|
338
383
|
/** The account's running Macs, newest first: queued, starting or ready. */
|
|
339
384
|
async function liveSessions() {
|
|
340
385
|
const answer = await request("GET", "/api/v1/mac/sessions");
|
|
@@ -345,7 +390,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
345
390
|
const identity = join(store.directory, `id_ed25519_${randomUUID()}`);
|
|
346
391
|
await run("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-C", "nomac-session", "-f", identity]);
|
|
347
392
|
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 } : {}) } };
|
|
393
|
+
request: { profile: "standard", limits: view.limits ?? {}, ...(view.access ? { access: view.access } : {}) }, ...previousDrive(store.load()) };
|
|
349
394
|
store.save(local); store.saveBaseline({});
|
|
350
395
|
const deadline = now() + wait * 1000;
|
|
351
396
|
for (;;) {
|
|
@@ -373,15 +418,25 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
373
418
|
const session = saveView(local, result.session);
|
|
374
419
|
// This folder's Mac has stopped, but the account may run another, started elsewhere.
|
|
375
420
|
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` : ""));
|
|
421
|
+
if (values.json) { print(JSON.stringify({ session, ...(result.image ? { image: result.image } : {}), ...project(local), ...(elsewhere ? { running_elsewhere: elsewhere.id } : {}) }, null, 2)); return session; }
|
|
422
|
+
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
423
|
return session;
|
|
379
424
|
}
|
|
425
|
+
if (command === "cancel") {
|
|
426
|
+
if (positionals.length !== 1 || !/^job_[a-f0-9]{40}$/.test(positionals[0])) throw new CliError("Usage: nomac cancel JOB_ID");
|
|
427
|
+
const local = localSession(), job = await hostedRequest(local.id, "POST", `/api/v1/mac/sessions/${sessionId(local.id)}/jobs/${positionals[0]}/cancel`, {}, { request, transport });
|
|
428
|
+
if (values.json) { print(JSON.stringify(job, null, 2)); return job; }
|
|
429
|
+
const ended = job.exit_code === null || job.exit_code === undefined ? (job.signal ? `signal ${job.signal}` : job.state) : `exit code ${job.exit_code}`;
|
|
430
|
+
print(job.cancelled ? `Cancelled ${job.id}: it and everything it started were stopped (${ended}). Its output so far: nomac output ${job.id}`
|
|
431
|
+
: job.state === "exited" ? `${job.id} had already finished (${job.reason === "cancelled" ? "cancelled" : ended}).` : `${job.id} is ${job.state}.`);
|
|
432
|
+
return job;
|
|
433
|
+
}
|
|
380
434
|
if (["exec", "job", "output"].includes(command)) {
|
|
381
435
|
const local = localSession(), base = `/api/v1/mac/sessions/${sessionId(local.id)}`;
|
|
382
436
|
let path, method = "GET", params = {};
|
|
383
437
|
if (command === "exec") {
|
|
384
438
|
if (!positionals.length || !argv.includes("--")) throw new CliError("Use nomac exec -- command args");
|
|
439
|
+
if (!local.workspaceDecided && !local.workspace) { await refresh(local); if (local.state === "ready") await placeWorkspace(local); }
|
|
385
440
|
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
441
|
// A person running a command once needs no key; scripts pass one so a lost response can be retried safely.
|
|
387
442
|
// Without a key, a long wait implies a long command, so the command's own limit follows it unless set.
|
|
@@ -389,7 +444,10 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
389
444
|
// A command may run an hour (the gateway's limit); waiting can cover all of it.
|
|
390
445
|
const waitFor = integer(values.wait, 30, 0, 3600);
|
|
391
446
|
const timeout = integer(values.timeout, values["request-key"] === undefined ? Math.max(300, waitFor) : 600, 1, 3600);
|
|
392
|
-
|
|
447
|
+
const line = isShellLine(positionals), dir = local.workspaceOnDrive ? local.workspace : null;
|
|
448
|
+
const remote = dir ? (line ? ["/bin/bash", "-lc", `cd ${shellQuote(dir)} && ${positionals[0]}`] : ["/bin/bash", "-lc", `cd ${shellQuote(dir)} && exec "$@"`, "nomac", ...positionals])
|
|
449
|
+
: line ? ["/bin/bash", "-lc", positionals[0]] : positionals;
|
|
450
|
+
method = "POST"; path = `${base}/jobs`; params = { requestKey: values["request-key"] ?? `cli-${randomUUID()}`, body: { argv: remote, timeout_seconds: timeout } };
|
|
393
451
|
} else {
|
|
394
452
|
if (positionals.length !== 1 || !/^job_[a-f0-9]{40}$/.test(positionals[0])) throw new CliError("Supply the existing job ID.");
|
|
395
453
|
path = `${base}/jobs/${positionals[0]}`;
|
|
@@ -511,12 +569,12 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
511
569
|
if (current?.mode === "hosted" && current.id && (!wanted || current.id === wanted) && live.some(s => s.id === current.id)) {
|
|
512
570
|
await refresh(current);
|
|
513
571
|
if (current.state === "ready") await ready(current);
|
|
514
|
-
return output({ session: current.snapshot, attached: `This folder already uses ${current.id}
|
|
572
|
+
return output({ session: current.snapshot, attached: `This folder already uses ${current.id}.`, ...project(current) });
|
|
515
573
|
}
|
|
516
574
|
const target = wanted ? live.find(s => s.id === wanted) : live.length === 1 ? live[0] : undefined;
|
|
517
575
|
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
576
|
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
|
|
577
|
+
return output({ session: local.snapshot, attached: `Attached to ${local.id}, started elsewhere: ssh, sync, pull and exec work from this folder.`, ...project(local) });
|
|
520
578
|
}
|
|
521
579
|
if (command === "start") {
|
|
522
580
|
if (positionals.length) throw new CliError("Usage: nomac start [--wait SECONDS] [--json]");
|
|
@@ -540,7 +598,8 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
540
598
|
const identity = join(store.directory, `id_ed25519_${randomUUID()}`);
|
|
541
599
|
await run("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-C", "nomac-session", "-f", identity]);
|
|
542
600
|
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 } : {}) }
|
|
601
|
+
request: { profile: "standard", public_key: readFileSync(`${identity}.pub`, "utf8").trim(), limits, ...(access ? { access } : {}) },
|
|
602
|
+
...previousDrive(store.load()) };
|
|
544
603
|
store.save(local); store.saveBaseline({});
|
|
545
604
|
}
|
|
546
605
|
if (!local.id) {
|
|
@@ -554,7 +613,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
554
613
|
const [running] = await liveSessions();
|
|
555
614
|
if (running) {
|
|
556
615
|
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
|
|
616
|
+
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
617
|
}
|
|
559
618
|
}
|
|
560
619
|
if (!previouslyUncertain && startRejections.has(error.code) && Number.isInteger(error.status) && error.status >= 400 && error.status <= 503) {
|
|
@@ -573,7 +632,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
573
632
|
await sleep(2000);
|
|
574
633
|
}
|
|
575
634
|
if (local.state === "ready") await ready(local);
|
|
576
|
-
return output({ session: local.snapshot, request_key: local.requestKey, ...(local.warning ? { warning: local.warning } : {}) });
|
|
635
|
+
return output({ session: local.snapshot, request_key: local.requestKey, ...project(local), ...(local.warning ? { warning: local.warning } : {}) });
|
|
577
636
|
}
|
|
578
637
|
const local = localSession();
|
|
579
638
|
if (command === "stop") {
|
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 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
|
}
|