@nomac/cli 0.11.1 → 0.11.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli.mjs +2 -0
- package/src/hosted-sessions.mjs +72 -11
- package/src/transport-command.mjs +1 -1
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -10,6 +10,7 @@ Start:
|
|
|
10
10
|
nomac login connect this device (approve in the browser)
|
|
11
11
|
nomac credits credit left; nomac credits buy --packs N adds more
|
|
12
12
|
nomac start start a Mac, or reconnect to yours
|
|
13
|
+
nomac attach use from this folder the Mac your agent started elsewhere (MCP, dashboard)
|
|
13
14
|
nomac stop stop the Mac: deletes it and its files (not ~/drive), billing ends
|
|
14
15
|
|
|
15
16
|
Use it:
|
|
@@ -101,6 +102,7 @@ export async function main(argv) {
|
|
|
101
102
|
}
|
|
102
103
|
// falls through: everyone else sees their Mac
|
|
103
104
|
case "start":
|
|
105
|
+
case "attach":
|
|
104
106
|
case "exec":
|
|
105
107
|
case "job":
|
|
106
108
|
case "output":
|
package/src/hosted-sessions.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { chmodSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import { parseArgs } from "node:util";
|
|
@@ -52,6 +52,8 @@ const HELP = `Nomac cloud Mac sessions
|
|
|
52
52
|
start a screen video; nomac record stop saves it
|
|
53
53
|
as nomac-recording.mp4 (or -o) with a share link
|
|
54
54
|
nomac status [--json] state, cost so far and when it stops
|
|
55
|
+
nomac attach [SESSION_ID] use the account's running Mac from this folder, when your
|
|
56
|
+
agent started it elsewhere (MCP, the dashboard); start does it too
|
|
55
57
|
nomac stop [--wait 60] [--json] stop the Mac: deletes it and its files (not ~/drive)
|
|
56
58
|
|
|
57
59
|
Login once with nomac login. The CLI creates a local SSH key; it sends only the
|
|
@@ -196,10 +198,10 @@ async function waitForCommand(id, base, started, waitSeconds, { request, transpo
|
|
|
196
198
|
const unknown = job.state === "unknown";
|
|
197
199
|
const busyNote = busy ? "The Mac is busy (for example, the iOS Simulator is booting) and answers slowly. " : "";
|
|
198
200
|
const next = unknown ? `The Mac could not confirm whether ${job.id} finished. Inspect it with nomac job ${job.id}; do not rerun it.`
|
|
199
|
-
: `${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
|
|
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.`;
|
|
200
202
|
if (json) print(JSON.stringify({ ...job, ...(busy ? { busy: true } : {}), next_step: next }, null, 2));
|
|
201
203
|
else if (unknown) stderr(`nomac: ${next}\n`);
|
|
202
|
-
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
|
|
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`);
|
|
203
205
|
exitWith(unknown ? 1 : STILL_RUNNING);
|
|
204
206
|
return job;
|
|
205
207
|
}
|
|
@@ -286,11 +288,11 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
286
288
|
catch (error) { throw new CliError(error.message); }
|
|
287
289
|
const { values, positionals } = parsed;
|
|
288
290
|
if (values.help) return print(command === "desktop" ? DESKTOP_HELP : HELP);
|
|
289
|
-
const wait = ["start", "stop"].includes(command) ? integer(values.wait, command === "
|
|
291
|
+
const wait = ["start", "attach", "stop"].includes(command) ? integer(values.wait, command === "stop" ? 60 : 300, 0, command === "stop" ? 300 : 600) : 0;
|
|
290
292
|
if (command === "start" && values["request-key"] !== undefined && !validRequestKey(values["request-key"])) throw new CliError("Use a request key of 8–128 letters, digits, underscores, periods, colons or hyphens.");
|
|
291
293
|
const output = result => {
|
|
292
294
|
// Sync, pull and the other commands answer with their own result, which has no session.
|
|
293
|
-
if (values.json || !["start", "stop"].includes(command)) { print(JSON.stringify(result, null, 2)); return result; }
|
|
295
|
+
if (values.json || !["start", "attach", "stop"].includes(command)) { print(JSON.stringify(result, null, 2)); return result; }
|
|
294
296
|
const billing = result.session?.billing;
|
|
295
297
|
const usage = billing?.mode === "weekly_included"
|
|
296
298
|
? `Included time used: ${billing.used_seconds} seconds. No extra charge.`
|
|
@@ -298,7 +300,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
298
300
|
const next = result.session.state === "ready" ? "Next: nomac sync . → nomac ssh"
|
|
299
301
|
: result.session.failure_code === "capacity_unavailable" ? "No Mac was free within 5 minutes; nothing was charged. Run nomac start again in a few minutes."
|
|
300
302
|
: result.session.state === "queued" ? "Waiting for a free Mac (waiting is free). Check again: nomac start" : "Check: nomac session status --json";
|
|
301
|
-
print(`${result.session.id}: ${result.session.state}\n${next}\n${usage}${result.warning ? `\n${result.warning}` : ""}`);
|
|
303
|
+
print(`${result.attached ? `${result.attached}\n` : ""}${result.session.id}: ${result.session.state}\n${next}\n${usage}${result.warning ? `\n${result.warning}` : ""}`);
|
|
302
304
|
return result;
|
|
303
305
|
};
|
|
304
306
|
const saveView = (local, view) => {
|
|
@@ -313,10 +315,44 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
313
315
|
if (local.state !== "ready") throw new CliError("Mac is not ready.", `Session ${local.id} is ${local.state}. Run nomac session status --json.`, undefined, "session_not_ready");
|
|
314
316
|
const access = await sessionAccess(local.id, request);
|
|
315
317
|
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");
|
|
318
|
+
if (local.attached && !local.keyAdded) await addKey(local);
|
|
316
319
|
Object.assign(local, { generation: access.generation, hostKey: access.ssh.host_key, target: `agent@nomac-${local.id}`, workspace: "/Users/agent/workspace", knownHosts: join(store.directory, "known_hosts") });
|
|
317
320
|
writeFileSync(local.knownHosts, `nomac-${local.id} ${access.ssh.host_key}\n`, { mode: 0o600 }); chmodSync(local.knownHosts, 0o600); store.save(local);
|
|
318
321
|
return local;
|
|
319
322
|
}
|
|
323
|
+
/**
|
|
324
|
+
* A Mac started elsewhere (by an agent over MCP, the dashboard or another folder) took no key
|
|
325
|
+
* from this folder: add this folder's own to its authorized keys with an ordinary command, so
|
|
326
|
+
* ssh, sync and pull work here too.
|
|
327
|
+
*/
|
|
328
|
+
async function addKey(local) {
|
|
329
|
+
const key = readFileSync(`${local.identity}.pub`, "utf8").trim();
|
|
330
|
+
const job = await request("POST", `/api/v1/mac/sessions/${sessionId(local.id)}/jobs?wait=30`, { idempotencyKey: `cli-key-${createHash("sha256").update(key).digest("hex").slice(0, 32)}`,
|
|
331
|
+
body: { argv: ["/bin/bash", "-c", 'umask 077; mkdir -p ~/.ssh && grep -qxF "$NOMAC_KEY" ~/.ssh/authorized_keys 2>/dev/null || printf "%s\n" "$NOMAC_KEY" >> ~/.ssh/authorized_keys'], env: { NOMAC_KEY: key }, timeout_seconds: 30 } });
|
|
332
|
+
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}`);
|
|
333
|
+
local.keyAdded = true; store.save(local);
|
|
334
|
+
}
|
|
335
|
+
/** The account's running Macs, newest first: queued, starting or ready. */
|
|
336
|
+
async function liveSessions() {
|
|
337
|
+
const answer = await request("GET", "/api/v1/mac/sessions");
|
|
338
|
+
return (Array.isArray(answer?.sessions) ? answer.sessions : []).filter(s => ["queued", "provisioning", "ready"].includes(s.state));
|
|
339
|
+
}
|
|
340
|
+
/** This folder now uses that Mac: a fresh key of its own, then ready() adds it once the Mac is ready. */
|
|
341
|
+
async function attachTo(view) {
|
|
342
|
+
const identity = join(store.directory, `id_ed25519_${randomUUID()}`);
|
|
343
|
+
await run("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-C", "nomac-session", "-f", identity]);
|
|
344
|
+
const local = { mode: "hosted", state: view.state, id: sessionId(view.id), identity, attached: true, outcomeUnknown: false, requestKey: `attach-${view.id}`,
|
|
345
|
+
request: { profile: "standard", limits: view.limits ?? {}, ...(view.access ? { access: view.access } : {}) } };
|
|
346
|
+
store.save(local); store.saveBaseline({});
|
|
347
|
+
const deadline = now() + wait * 1000;
|
|
348
|
+
for (;;) {
|
|
349
|
+
await refresh(local);
|
|
350
|
+
if (!["queued", "provisioning"].includes(local.state) || now() >= deadline) break;
|
|
351
|
+
await sleep(2000);
|
|
352
|
+
}
|
|
353
|
+
if (local.state === "ready") await ready(local);
|
|
354
|
+
return local;
|
|
355
|
+
}
|
|
320
356
|
const localSession = () => {
|
|
321
357
|
const local = store.load();
|
|
322
358
|
if (local?.mode !== "hosted" || !local.id) throw new CliError("No attached cloud session.", "Run nomac start to allocate or reconcile your Mac.");
|
|
@@ -332,8 +368,10 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
332
368
|
if (!loaded) { print(values.json ? JSON.stringify({ session: null }) : "No Mac in this folder. Start one: nomac start"); return null; }
|
|
333
369
|
const local = localSession(), result = await request("GET", `/api/v1/mac/sessions/${sessionId(local.id)}`);
|
|
334
370
|
const session = saveView(local, result.session);
|
|
335
|
-
|
|
336
|
-
|
|
371
|
+
// This folder's Mac has stopped, but the account may run another, started elsewhere.
|
|
372
|
+
const elsewhere = ["stopped", "failed"].includes(session.state) ? (await liveSessions().catch(() => [])).find(s => s.id !== session.id) : undefined;
|
|
373
|
+
if (values.json) { print(JSON.stringify({ session, ...(result.image ? { image: result.image } : {}), ...(elsewhere ? { running_elsewhere: elsewhere.id } : {}) }, null, 2)); return session; }
|
|
374
|
+
print(statusSummary(session, result.image) + (elsewhere ? `\nYour account's Mac ${elsewhere.id} is ${elsewhere.state}, started elsewhere: nomac attach` : ""));
|
|
337
375
|
return session;
|
|
338
376
|
}
|
|
339
377
|
if (["exec", "job", "output"].includes(command)) {
|
|
@@ -345,8 +383,9 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
345
383
|
// A person running a command once needs no key; scripts pass one so a lost response can be retried safely.
|
|
346
384
|
// Without a key, a long wait implies a long command, so the command's own limit follows it unless set.
|
|
347
385
|
// With a key the limit must not depend on --wait: a retry with another --wait is the same command.
|
|
348
|
-
|
|
349
|
-
const
|
|
386
|
+
// A command may run an hour (the gateway's limit); waiting can cover all of it.
|
|
387
|
+
const waitFor = integer(values.wait, 30, 0, 3600);
|
|
388
|
+
const timeout = integer(values.timeout, values["request-key"] === undefined ? Math.max(300, waitFor) : 600, 1, 3600);
|
|
350
389
|
method = "POST"; path = `${base}/jobs`; params = { requestKey: values["request-key"] ?? `cli-${randomUUID()}`, body: { argv: positionals, timeout_seconds: timeout } };
|
|
351
390
|
} else {
|
|
352
391
|
if (positionals.length !== 1 || !/^job_[a-f0-9]{40}$/.test(positionals[0])) throw new CliError("Supply the existing job ID.");
|
|
@@ -368,7 +407,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
368
407
|
throw error;
|
|
369
408
|
});
|
|
370
409
|
if (!waiting) return output(result);
|
|
371
|
-
return waitForCommand(local.id, base, result, integer(values.wait, 30, 0,
|
|
410
|
+
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) });
|
|
372
411
|
}
|
|
373
412
|
if (command === "screenshot") {
|
|
374
413
|
if (positionals.length) throw new CliError("Usage: nomac screenshot [--simulator] [-o FILE]");
|
|
@@ -462,6 +501,20 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
462
501
|
return run("ssh", [...sshArgs(local, { batch: false }), ...(!positionals.length && process.stdin.isTTY ? ["-t"] : []), ...(values.forward ?? []).flatMap(v => ["-L", forwardingArg(v)]), "-o", "ExitOnForwardFailure=yes", local.target, workspaceCommand(local, positionals)], { inherit: true });
|
|
463
502
|
}
|
|
464
503
|
return store.locked(async () => {
|
|
504
|
+
if (command === "attach") {
|
|
505
|
+
if (positionals.length > 1) throw new CliError("Usage: nomac attach [SESSION_ID] [--wait SECONDS] [--json]");
|
|
506
|
+
const live = await liveSessions(), wanted = positionals[0] && sessionId(positionals[0]);
|
|
507
|
+
const current = store.load();
|
|
508
|
+
if (current?.mode === "hosted" && current.id && (!wanted || current.id === wanted) && live.some(s => s.id === current.id)) {
|
|
509
|
+
await refresh(current);
|
|
510
|
+
if (current.state === "ready") await ready(current);
|
|
511
|
+
return output({ session: current.snapshot, attached: `This folder already uses ${current.id}.` });
|
|
512
|
+
}
|
|
513
|
+
const target = wanted ? live.find(s => s.id === wanted) : live.length === 1 ? live[0] : undefined;
|
|
514
|
+
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");
|
|
515
|
+
const local = await attachTo(target);
|
|
516
|
+
return output({ session: local.snapshot, attached: `Attached to ${local.id}, started elsewhere: ssh, sync, pull and exec work from this folder.` });
|
|
517
|
+
}
|
|
465
518
|
if (command === "start") {
|
|
466
519
|
if (positionals.length) throw new CliError("Usage: nomac start [--wait SECONDS] [--json]");
|
|
467
520
|
let local = store.load();
|
|
@@ -493,6 +546,14 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
493
546
|
let result;
|
|
494
547
|
try { result = await request("POST", "/api/v1/mac/sessions", { idempotencyKey: local.requestKey, body: local.request }); }
|
|
495
548
|
catch (error) {
|
|
549
|
+
// One Mac runs per account: when it was started elsewhere (an agent over MCP, the dashboard), use it from here.
|
|
550
|
+
if (error.code === "session_limit_reached" && values["request-key"] === undefined) {
|
|
551
|
+
const [running] = await liveSessions();
|
|
552
|
+
if (running) {
|
|
553
|
+
local = await attachTo(running);
|
|
554
|
+
return output({ session: local.snapshot, attached: `Your account's Mac ${local.id} was already running (started elsewhere): this folder now uses it.` });
|
|
555
|
+
}
|
|
556
|
+
}
|
|
496
557
|
if (!previouslyUncertain && startRejections.has(error.code) && Number.isInteger(error.status) && error.status >= 400 && error.status <= 503) {
|
|
497
558
|
local.state = "failed"; local.rejection = error.code; store.save(local);
|
|
498
559
|
}
|
|
@@ -44,7 +44,7 @@ export async function transportCommand(args, { request = macRequest, print = con
|
|
|
44
44
|
const options = parsed(rest.slice(0, separator), ["--request-key", "--timeout"]);
|
|
45
45
|
if (!/^[A-Za-z0-9_.:-]{8,128}$/.test(options["--request-key"] ?? "")) throw new CliError("Supply a stable --request-key of 8 to 128 safe characters.");
|
|
46
46
|
const timeout = Number(options["--timeout"] ?? 300);
|
|
47
|
-
if (!Number.isInteger(timeout) || timeout < 1 || timeout >
|
|
47
|
+
if (!Number.isInteger(timeout) || timeout < 1 || timeout > 3600) throw new CliError("--timeout must be 1 to 3600 seconds.");
|
|
48
48
|
result = await request("POST", `${base}/jobs`, { requestKey: options["--request-key"], body: { argv: rest.slice(separator + 1), timeout_seconds: timeout } });
|
|
49
49
|
} else if (command === "job" || command === "output") {
|
|
50
50
|
const [job, ...flags] = rest;
|