@nomac/cli 0.3.0 → 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/bin/nomac.mjs +1 -1
- package/package.json +2 -2
- package/src/hosted-sessions.mjs +1 -1
- package/src/mac-tools.mjs +1 -1
- package/src/session-transfer.mjs +24 -7
- package/src/transport-command.mjs +1 -1
package/bin/nomac.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { main } from "../src/cli.mjs";
|
|
|
3
3
|
|
|
4
4
|
main(process.argv.slice(2)).catch((err) => {
|
|
5
5
|
const args = process.argv.slice(2);
|
|
6
|
-
if (["start", "sync", "pull", "stop", "session", "transport"].includes(args[0]) && args.includes("--json")) {
|
|
6
|
+
if (["start", "sync", "pull", "stop", "session", "transport", "exec", "job", "output"].includes(args[0]) && args.includes("--json")) {
|
|
7
7
|
console.error(JSON.stringify({ error: { code: err?.code ?? "session_error", message: err?.friendly ?? err?.message ?? String(err), ...(err?.details ? { details: err.details } : {}) } }));
|
|
8
8
|
} else console.error(err?.friendly ?? err?.message ?? String(err));
|
|
9
9
|
process.exit(err?.exitCode ?? 1);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nomac/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Cloud Mac sessions over SSH and HTTPS, plus iOS builds and publishing
|
|
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": {
|
package/src/hosted-sessions.mjs
CHANGED
|
@@ -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,
|
|
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
|
];
|
package/src/session-transfer.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
158
|
-
|
|
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}
|
|
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 =
|
|
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"] ??
|
|
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") {
|