@nomac/cli 0.11.9 → 0.11.10

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nomac/cli",
3
- "version": "0.11.9",
3
+ "version": "0.11.10",
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",
package/skill/SKILL.md CHANGED
@@ -27,7 +27,8 @@ commands and tools. Mac time is prepaid credit, separate from Builder.
27
27
  Hosted MCP: https://mcp.nomac.app/mcp with `Authorization: Bearer <nmk_key>`.
28
28
  3. `nomac start --json`, wait until ready, `nomac sync .`, then `nomac ssh`.
29
29
  Run commands with `nomac exec -- COMMAND`: it prints the output as it
30
- arrives and exits with the command's code, waiting at most 30 s. For builds and tests use
30
+ arrives and exits with the command's code, waiting at most 30 s (or the
31
+ `--timeout` given, up to 600 s). For builds and tests use
31
32
  `nomac exec --wait 600 -- COMMAND`. Exit code 75 means still running: wait
32
33
  with `nomac job JOB_ID --wait 600`, never rerun. In scripts add
33
34
  `--request-key stable-key-0001`. A command may run an hour (`--timeout
@@ -48,7 +48,8 @@ const HELP = `Nomac cloud Mac sessions
48
48
  nomac exec [--wait 30] -- command args
49
49
  runs it, prints its output as it arrives,
50
50
  exits with its code.
51
- Waits up to 30 s; for builds and tests use --wait 600.
51
+ Waits up to 30 s, or with --timeout N up to N s
52
+ (at most 600); for builds and tests use --wait 600.
52
53
  Exit code 75 means still running: see nomac job.
53
54
  (--request-key KEY makes a scripted retry safe)
54
55
  nomac job JOB_ID [--wait 600] wait for a command that outlasted --wait
@@ -178,11 +179,11 @@ export const STILL_RUNNING = 75;
178
179
  const POLL_MS = [250, 500];
179
180
  const NOTICE_MS = 5000;
180
181
  /**
181
- * nomac exec waits like a local command: up to --wait seconds (default 30), then
182
- * prints stdout and stderr and exits with the command's own exit code. The wait
183
- * is a ceiling: a quick command returns as soon as it finishes. A command still
184
- * running keeps running on the Mac; the CLI says how to wait for it and exits
185
- * 75. --wait 0 only starts it.
182
+ * nomac exec waits like a local command: up to --wait seconds (default 30, or
183
+ * --timeout's up to 600), then prints stdout and stderr and exits with the
184
+ * command's own exit code. The wait is a ceiling: a quick command returns as soon
185
+ * as it finishes. A command still running keeps running on the Mac; the CLI
186
+ * says how to wait for it and exits 75. --wait 0 only starts it.
186
187
  */
187
188
  /** "13:05 UTC", when a running command's time limit stops it. */
188
189
  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.` : "";
@@ -487,7 +488,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
487
488
  }
488
489
  if (["exec", "job", "output"].includes(command)) {
489
490
  const local = localSession(), base = `/api/v1/mac/sessions/${sessionId(local.id)}`;
490
- let path, method = "GET", params = {};
491
+ let path, method = "GET", params = {}, waitSeconds = integer(values.wait, 30, 0, 3600);
491
492
  if (command === "exec") {
492
493
  if (!positionals.length || !argv.includes("--")) throw new CliError("Use nomac exec -- command args");
493
494
  if (!local.workspaceDecided && !local.workspace) { await refresh(local); if (local.state === "ready") await placeWorkspace(local); }
@@ -496,8 +497,11 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
496
497
  // Without a key, a long wait implies a long command, so the command's own limit follows it unless set.
497
498
  // With a key the limit must not depend on --wait: a retry with another --wait is the same command.
498
499
  // A command may run an hour (the gateway's limit); waiting can cover all of it.
499
- const waitFor = integer(values.wait, 30, 0, 3600);
500
- const timeout = integer(values.timeout, values["request-key"] === undefined ? Math.max(300, waitFor) : 600, 1, 3600);
500
+ // A command given a long --timeout and no --wait is waited for too, up to 10 minutes (what an
501
+ // agent's shell usually allows); the line printed after 5 s says how to go on if interrupted.
502
+ const given = values.timeout === undefined ? null : integer(values.timeout, 0, 1, 3600);
503
+ const waitFor = waitSeconds = integer(values.wait, given === null ? 30 : Math.min(given, 600), 0, 3600);
504
+ const timeout = given ?? (values["request-key"] === undefined ? Math.max(300, waitFor) : 600);
501
505
  const line = isShellLine(positionals), dir = local.workspaceOnDrive ? local.workspace : null;
502
506
  const remote = dir ? (line ? ["/bin/bash", "-lc", `cd ${shellQuote(dir)} && ${positionals[0]}`] : ["/bin/bash", "-lc", `cd ${shellQuote(dir)} && exec "$@"`, "nomac", ...positionals])
503
507
  : line ? ["/bin/bash", "-lc", positionals[0]] : positionals;
@@ -528,7 +532,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
528
532
  if (at) next[job] = { stdout: at.stdout, stderr: at.stderr };
529
533
  store.saveJobCursors(next);
530
534
  };
531
- 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,
535
+ return waitForCommand(local.id, base, result, waitSeconds, { request, transport, cache, print, stdout, stderr, exitWith, sleep, now, json: Boolean(values.json), timeoutSeconds: params.body?.timeout_seconds,
532
536
  cursors: command === "job" ? kept[result.id] ?? null : null, keepCursors });
533
537
  }
534
538
  if (command === "screenshot") {
@@ -642,7 +646,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
642
646
  do {
643
647
  again = false;
644
648
  try {
645
- const result = await store.locked(async () => syncProject(await ready(localSession()), store, { overwrite: values.overwrite, deleteMissing: values.delete, run }));
649
+ const result = await store.locked(async () => syncProject(await ready(localSession()), store, { overwrite: values.overwrite, deleteMissing: values.delete, run }), "nomac sync --watch");
646
650
  skipped = result.skipped;
647
651
  if (first) print(`${describeSync(result)}\nWatching ${store.project} for changes; Ctrl-C stops.`);
648
652
  else if (result.changed.length || result.deleted?.length || result.kept_on_mac?.length) print(`${clock()} ${describeSync({ ...result, skipped: [] })}`);
@@ -763,11 +767,13 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
763
767
  return output(result);
764
768
  }
765
769
  if (command === "pull") {
770
+ // Like cp, the Mac's path first: say the command that does it.
771
+ if (positionals.length === 2 && values.from === undefined) throw new CliError("pull usage", `nomac pull takes one path, where the files go here; the Mac's path goes in --from: nomac pull ${shellWords([positionals[1]])} --from ${shellWords([positionals[0]])}`);
766
772
  if (positionals.length !== 1) throw new CliError("Usage: nomac pull LOCAL_PATH [--from PATH_ON_THE_MAC]");
767
773
  const result = await pullProject(local, store, positionals[0], { from: values.from, overwrite: values.overwrite, dryRun: values["dry-run"], run });
768
774
  if (!values.json) { print(describePull(result)); return result; }
769
775
  return output(result);
770
776
  }
771
777
  throw new CliError("Unknown cloud session command.");
772
- });
778
+ }, `nomac ${command}`);
773
779
  }
@@ -4,6 +4,20 @@ import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { CliError } from "./api.mjs";
6
6
 
7
+ /** Who holds a folder's lock, or null for a lock without a readable record (an older nomac's). */
8
+ function lockOwner(file) {
9
+ try {
10
+ const owner = JSON.parse(readFileSync(file, "utf8"));
11
+ return Number.isSafeInteger(owner?.pid) && owner.pid > 0 ? { pid: owner.pid, command: String(owner.command ?? "a nomac command"), started_at: String(owner.started_at ?? "") } : null;
12
+ } catch { return null; }
13
+ }
14
+
15
+ /** Whether a process with this ID exists (one of another user's counts). */
16
+ function running(pid) {
17
+ try { process.kill(pid, 0); return true; }
18
+ catch (error) { return error.code === "EPERM"; }
19
+ }
20
+
7
21
  /** Session metadata stays outside the project: a checkout cannot choose an SSH target. */
8
22
  export function sessionStore(projectDir = process.cwd(), stateDir = join(homedir(), ".nomac", "sessions")) {
9
23
  const project = realpathSync(projectDir);
@@ -39,12 +53,30 @@ export function sessionStore(projectDir = process.cwd(), stateDir = join(homedir
39
53
  savePulled: (value) => write(pulledFile, value),
40
54
  jobCursors: () => read(cursorsFile) ?? {},
41
55
  saveJobCursors: (value) => write(cursorsFile, value),
42
- async locked(operation) {
43
- const lock = join(directory, "operation.lock");
44
- try { mkdirSync(lock, { mode: 0o700 }); }
56
+ /** Runs operation (nomac sync, pull, start…) alone for this folder; `what` names it to a second one. */
57
+ async locked(operation, what = "a nomac command") {
58
+ const lock = join(directory, "operation.lock"), ownerFile = join(lock, "owner.json");
59
+ const take = () => {
60
+ mkdirSync(lock, { mode: 0o700 });
61
+ writeFileSync(ownerFile, JSON.stringify({ pid: process.pid, command: what, started_at: new Date().toISOString() }) + "\n", { mode: 0o600 });
62
+ };
63
+ try { take(); }
45
64
  catch (error) {
46
65
  if (error.code !== "EEXIST") throw error;
47
- throw new CliError("session busy", `Another session operation is running. If it was interrupted, verify it has ended before removing ${lock}.`);
66
+ const owner = lockOwner(ownerFile);
67
+ if (!owner) throw new CliError("session busy", `Another nomac command is using this folder (an older nomac's lock, which does not say which). If no other nomac command is running, remove it: rm -r '${lock}'`);
68
+ if (running(owner.pid)) throw new CliError("session busy", `Another nomac command is using this folder: ${owner.command} (pid ${owner.pid}, since ${owner.started_at}). Wait for it to finish, or stop it: kill ${owner.pid}. If pid ${owner.pid} is not nomac, remove the lock: rm -r '${lock}'`);
69
+ // Its process has ended (killed, or its terminal closed): move that lock aside, the same one
70
+ // only (another command may have just replaced it), and take it.
71
+ const aside = `${lock}.${randomUUID()}.ended`;
72
+ try { renameSync(lock, aside); } catch (moved) { if (moved.code !== "ENOENT") throw moved; }
73
+ if (existsSync(aside) && JSON.stringify(lockOwner(join(aside, "owner.json"))) !== JSON.stringify(owner)) {
74
+ try { renameSync(aside, lock); } catch { /* its command's own cleanup removes it */ }
75
+ throw new CliError("session busy", "Another nomac command is using this folder. Wait for it to finish.");
76
+ }
77
+ rmSync(aside, { recursive: true, force: true });
78
+ try { take(); }
79
+ catch (again) { if (again.code === "EEXIST") throw new CliError("session busy", "Another nomac command is using this folder. Wait for it to finish."); throw again; }
48
80
  }
49
81
  try { return await operation(); }
50
82
  finally { rmSync(lock, { recursive: true, force: true }); }
@@ -296,7 +296,13 @@ export async function syncProject(session, store, { overwrite = false, dryRun =
296
296
  const deleted = removable.length ? await removeOnMac(session, removable, { run }) : [];
297
297
  for (const path of [...deleted, ...gone.filter(path => onMac[path] === "missing")]) delete baseline[path];
298
298
  store.saveBaseline(baseline);
299
- 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.");
299
+ if (changedDuringTransfer.length) {
300
+ const one = changedDuringTransfer.length === 1;
301
+ const error = new CliError("transfer changed during verification", `Changed here while being sent: ${listed(changedDuringTransfer)}. The Mac may not have ${one ? "its" : "their"} latest version; once edits stop, run nomac sync again to send ${one ? "it" : "them"}.${conflicts.length
302
+ ? ` Not sent, because changed on the Mac since this folder last synced: ${listed(conflicts)}. Keep the Mac's version: nomac pull ${conflicts[0]}. Replace it with yours: nomac sync . --overwrite` : " Everything else was sent."}`);
303
+ error.details = { changed_during_transfer: changedDuringTransfer, ...(conflicts.length ? { conflicts } : {}) };
304
+ throw error;
305
+ }
300
306
  if (conflicts.length) throw syncConflictError(conflicts, changed.length, deleteMissing ? { deleted, kept_on_mac: keptOnMac } : {});
301
307
  return { files: paths.length, changed, ...(deleteMissing ? { deleted, kept_on_mac: keptOnMac } : {}), ...summary, output };
302
308
  }
package/src/sessions.mjs CHANGED
@@ -140,5 +140,5 @@ export async function sessionCommand(command, argv, { store: suppliedStore, run
140
140
  return output(result, describePull(result));
141
141
  }
142
142
  throw new CliError("unknown session command");
143
- });
143
+ }, `nomac ${command}`);
144
144
  }