@nomac/cli 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nomac/cli",
3
- "version": "0.3.1",
4
- "description": "Cloud Mac sessions over SSH and HTTPS, plus iOS builds and publishing — built for agents.",
3
+ "version": "0.4.0",
4
+ "description": "Cloud Mac sessions over SSH and HTTPS, plus iOS builds and publishing \u2014 built for agents.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
7
7
  "bin": {
@@ -109,7 +109,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
109
109
  let path, method = "GET", params = {};
110
110
  if (command === "exec") {
111
111
  if (!positionals.length || !argv.includes("--") || !/^[A-Za-z0-9_.:-]{8,128}$/.test(values["request-key"] ?? "")) throw new CliError("Use nomac exec --request-key STABLE_KEY -- command args");
112
- method = "POST"; path = `${base}/jobs`; params = { requestKey: values["request-key"], body: { argv: positionals, timeout_seconds: integer(values.timeout, 60, 1, 600) } };
112
+ method = "POST"; path = `${base}/jobs`; params = { requestKey: values["request-key"], body: { argv: positionals, timeout_seconds: integer(values.timeout, 300, 1, 600) } };
113
113
  } else {
114
114
  if (positionals.length !== 1 || !/^job_[a-f0-9]{40}$/.test(positionals[0])) throw new CliError("Supply the existing job ID.");
115
115
  path = `${base}/jobs/${positionals[0]}`;
package/src/mac-tools.mjs CHANGED
@@ -12,7 +12,7 @@ export const MAC_TOOLS = [
12
12
  { name: "list_mac_sessions", description: "Find existing Mac sessions, including after losing a response. Shows state, cost, limits and cleanup.", inputSchema: schema({}), handler: async () => text(await api("GET", "/api/v1/mac/sessions")) },
13
13
  { name: "get_mac_session", description: "Read live Mac allocation state, cost, funded time and cleanup confirmation. queued/provisioning means wait; stopping is not yet confirmed cleanup.", inputSchema: schema({ session_id: session }, ["session_id"]), handler: async args => text(await api("GET", base(args))) },
14
14
  { 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))) },
15
- { name: "exec_mac", description: "Run any argv command in your cloud Mac workspace. Returns a durable job ID; poll get_mac_job and read_mac_output. Reuse request_key and identical arguments after lost responses; never submit a new key blindly. Use bash -lc explicitly for shell syntax.", inputSchema: schema({ session_id: session, request_key: key, argv: { type: "array", items: { type: "string" }, minItems: 1 }, timeout_seconds: { type: "integer", minimum: 1, maximum: 600 } }, ["session_id", "request_key", "argv"]), handler: async ({ session_id, request_key, ...body }) => text(await api("POST", `${base({ session_id })}/jobs`, { body, idempotencyKey: request_key })) },
15
+ { name: "exec_mac", description: "Run any argv command in your cloud Mac workspace. Returns a durable job ID; poll get_mac_job and read_mac_output. Reuse request_key and identical arguments after lost responses; never submit a new key blindly. Use bash -lc explicitly for shell syntax. timeout_seconds defaults to 300 and caps at 600; raise it for slow work such as a first simulator boot, which needs about 90 seconds.", inputSchema: schema({ session_id: session, request_key: key, argv: { type: "array", items: { type: "string" }, minItems: 1 }, timeout_seconds: { type: "integer", minimum: 1, maximum: 600 } }, ["session_id", "request_key", "argv"]), handler: async ({ session_id, request_key, ...body }) => text(await api("POST", `${base({ session_id })}/jobs`, { body, idempotencyKey: request_key })) },
16
16
  { 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)}`)) },
17
17
  { 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" })}`)) },
18
18
  ];
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { constants, copyFileSync, createReadStream, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, chmodSync, utimesSync } from "node:fs";
3
- import { dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
3
+ import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import ignoreFactory from "ignore";
6
6
  import { CliError } from "./api.mjs";
@@ -50,7 +50,7 @@ export async function fingerprint(path) {
50
50
  }
51
51
 
52
52
  export function safeRelative(path) {
53
- if (typeof path !== "string" || isAbsolute(path) || path.split(/[\\/]/).includes("..") || /[\x00-\x1f\x7f]/.test(path) || path.includes("\\")) throw new CliError("invalid relative path", "Use a path within the workspace, without '..' or control characters.");
53
+ if (typeof path !== "string" || isAbsolute(path) || path.split(/[\\/]/).includes("..") || /[\x00-\x1f\x7f]/.test(path) || path.includes("\\")) throw new CliError("invalid relative path", "Use a path inside the Mac's workspace, relative and without '..' or control characters. Copy anything outside the workspace, such as /tmp, into it first.");
54
54
  return posix.normalize(path || ".").replace(/\/$/, "") || ".";
55
55
  }
56
56
 
@@ -154,13 +154,30 @@ export function assertSafeDestination(root, target) {
154
154
 
155
155
  export async function pullProject(session, store, destination, { from = destination, overwrite = false, dryRun = false, run = runProcess } = {}) {
156
156
  destination = safeRelative(destination); from = safeRelative(from);
157
- const localRoot = resolve(store.project, destination);
158
- assertSafeDestination(store.project, localRoot);
157
+ // Local safety first: a symlinked destination is refused before anything is
158
+ // asked of the Mac.
159
+ const requested = resolve(store.project, destination);
160
+ assertSafeDestination(store.project, requested);
161
+ // rsync reaches its source by cd'ing into it, so a single file has to be fetched
162
+ // from its parent directory. Probe the remote path: pulling one file (a
163
+ // screenshot, an .ipa) used to fail with a bare rsync "not a directory".
164
+ const probe = from === "." ? {} : await remoteFingerprints(session, [from], { run });
165
+ const kind = from === "." ? "directory" : probe[from];
166
+ if (kind === "missing") {
167
+ throw new CliError("no such workspace path", `The Mac has no ${from} in its workspace. Paths are relative to the workspace; copy anything outside it, such as /tmp, into the workspace first.`);
168
+ }
169
+ if (kind === "symlink") throw new CliError("unsafe remote path", `${from} is a symlink. Resolve it inside the workspace first.`);
170
+ const single = typeof kind === "string" && kind.startsWith("file:") ? posix.basename(from) : null;
171
+ const source = single ? posix.dirname(from) : from;
172
+ // cp semantics for a single file: into the directory if one exists, else to that name.
173
+ const intoDirectory = !single || (existsSync(requested) && lstatSync(requested).isDirectory());
174
+ const rename = intoDirectory ? null : basename(requested);
175
+ const localRoot = intoDirectory ? requested : dirname(requested);
159
176
  const staging = mkdtempSync(join(store.directory, "pull-"));
160
177
  const backup = join(store.directory, "backups", randomUUID());
161
178
  try {
162
179
  // Download into an empty private directory; rsync never receives the user's working tree as destination.
163
- await run("rsync", [...transferArgs(), `${session.target}:./`, `${staging}/`], { env: transferEnv(session, from) });
180
+ await run("rsync", [...transferArgs(), `${session.target}:./${single ?? ""}`, `${staging}/`], { env: transferEnv(session, source) });
164
181
  const files = [];
165
182
  const walk = (directory) => {
166
183
  for (const entry of readdirSync(directory, { withFileTypes: true })) {
@@ -173,11 +190,11 @@ export async function pullProject(session, store, destination, { from = destinat
173
190
  walk(staging);
174
191
  const baseline = Object.assign(Object.create(null), store.baseline()), plan = [], conflicts = [];
175
192
  for (const path of files) {
176
- const target = join(localRoot, path), incoming = await fingerprint(join(staging, path));
193
+ const target = join(localRoot, rename ?? path), incoming = await fingerprint(join(staging, path));
177
194
  assertSafeDestination(store.project, target);
178
195
  const current = existsSync(target) ? await fingerprint(target) : "missing";
179
196
  if (current === "other") throw new CliError("incompatible local path", `A directory or special file occupies ${target}.`);
180
- const remotePath = from === "." ? path : `${from.replace(/\/$/, "")}/${path}`;
197
+ const remotePath = source === "." ? path : `${source.replace(/\/$/, "")}/${path}`;
181
198
  // A baseline is meaningful only when downloading to the original project-relative path.
182
199
  const samePath = relative(store.project, target).split(sep).join("/") === remotePath.split(sep).join("/");
183
200
  if (current !== "missing" && current !== incoming && (!samePath || baseline[remotePath] !== current)) conflicts.push(path);
@@ -43,7 +43,7 @@ export async function transportCommand(args, { request = macRequest, print = con
43
43
  if (separator === -1 || separator === rest.length - 1) throw new CliError("Use -- before the command and its arguments.");
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
- const timeout = Number(options["--timeout"] ?? 60);
46
+ const timeout = Number(options["--timeout"] ?? 300);
47
47
  if (!Number.isInteger(timeout) || timeout < 1 || timeout > 600) throw new CliError("--timeout must be 1 to 600 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") {