@nomac/cli 0.2.5 → 0.3.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.
@@ -0,0 +1,91 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { readFileSync, statSync, writeFileSync, linkSync, unlinkSync } from "node:fs";
3
+ import { resolve, dirname, join } from "node:path";
4
+ import { CliError } from "./api.mjs";
5
+ import { macRequest, sessionId } from "./mac-transport.mjs";
6
+
7
+ const HELP = `Nomac 2.0 transport probe (an operator-configured SSH target; no allocation or billing)
8
+
9
+ Set NOMAC_GATEWAY_URL and NOMAC_SESSION_TOKEN outside the project.
10
+ nomac tunnel <session> SSH ProxyCommand byte stream
11
+ nomac transport status <session> connection capabilities
12
+ nomac transport exec <session> --request-key KEY [--timeout SECONDS] -- command args...
13
+ nomac transport job <session> <job> inspect the existing job
14
+ nomac transport output <session> <job> [--cursor N] [--stream stdout|stderr]
15
+ nomac transport put <session> <file> upload a checksummed probe blob (up to 8 MiB)
16
+ nomac transport get <session> <sha256> <file> download a probe blob without overwriting
17
+
18
+ Results are JSON. Output chunks are base64 with byte cursors, so binary output is lossless.
19
+ Reuse the same request key after a lost response. Commands are never automatically rerun.
20
+ Probe blobs do not yet implement project sync; ordinary SSH/rsync remains available.`;
21
+
22
+ function parsed(args, allowed) {
23
+ const values = {};
24
+ for (let index = 0; index < args.length; index++) {
25
+ const flag = args[index];
26
+ if (flag === "--json") continue;
27
+ if (!allowed.includes(flag) || index + 1 >= args.length || values[flag] !== undefined) throw new CliError(`Unexpected transport option: ${flag}`);
28
+ values[flag] = args[++index];
29
+ }
30
+ return values;
31
+ }
32
+
33
+ export async function transportCommand(args, { request = macRequest, print = console.log } = {}) {
34
+ if (!args.length || ["help", "--help"].includes(args[0])) return print(HELP);
35
+ const [command, value, ...rest] = args;
36
+ const id = sessionId(value);
37
+ const base = `/api/v1/mac/sessions/${id}`;
38
+ let result;
39
+ if (command === "status") {
40
+ parsed(rest, []); result = await request("GET", base);
41
+ } else if (command === "exec") {
42
+ const separator = rest.indexOf("--");
43
+ if (separator === -1 || separator === rest.length - 1) throw new CliError("Use -- before the command and its arguments.");
44
+ const options = parsed(rest.slice(0, separator), ["--request-key", "--timeout"]);
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);
47
+ if (!Number.isInteger(timeout) || timeout < 1 || timeout > 600) throw new CliError("--timeout must be 1 to 600 seconds.");
48
+ result = await request("POST", `${base}/jobs`, { requestKey: options["--request-key"], body: { argv: rest.slice(separator + 1), timeout_seconds: timeout } });
49
+ } else if (command === "job" || command === "output") {
50
+ const [job, ...flags] = rest;
51
+ if (!/^job_[a-f0-9]{40}$/.test(job ?? "")) throw new CliError("Invalid job ID.");
52
+ const options = parsed(flags, command === "output" ? ["--cursor", "--stream"] : []);
53
+ const cursor = options["--cursor"] ?? "0", stream = options["--stream"] ?? "stdout";
54
+ if (!/^\d{1,16}$/.test(cursor) || !Number.isSafeInteger(Number(cursor)) || !["stdout", "stderr"].includes(stream)) throw new CliError("Invalid output stream or cursor.");
55
+ result = await request("GET", `${base}/jobs/${job}${command === "output" ? `/output?cursor=${cursor}&stream=${stream}` : ""}`);
56
+ } else if (command === "put") {
57
+ const [file, ...flags] = rest; parsed(flags, []);
58
+ if (!file) throw new CliError("Supply the file to upload.");
59
+ const info = statSync(file);
60
+ if (!info.isFile() || info.size > 8 * 1024 * 1024) throw new CliError("The probe accepts regular files up to 8 MiB.");
61
+ const data = readFileSync(file);
62
+ if (data.length > 8 * 1024 * 1024) throw new CliError("The file exceeded the probe limit.");
63
+ const digest = createHash("sha256").update(data).digest("hex");
64
+ let offset = 0;
65
+ do {
66
+ result = await request("PUT", `${base}/blobs/${digest}?size=${data.length}&offset=${offset}`, { raw: data.subarray(offset, offset + 256 * 1024) });
67
+ offset += 256 * 1024;
68
+ } while (offset < data.length && !result.complete);
69
+ if (!result.complete || result.id !== digest) throw new CliError("The upload was not confirmed. Retry the same file to resume.", undefined, undefined, "transfer_unconfirmed");
70
+ } else if (command === "get") {
71
+ const [digest, file, ...flags] = rest; parsed(flags, []);
72
+ if (!/^[a-f0-9]{64}$/.test(digest ?? "") || !file) throw new CliError("Supply the blob SHA-256 and destination file.");
73
+ const chunks = []; let cursor = 0, size;
74
+ for (;;) {
75
+ const chunk = await request("GET", `${base}/blobs/${digest}?cursor=${cursor}`);
76
+ const data = Buffer.from(chunk.data_base64 ?? "", "base64");
77
+ size ??= chunk.size;
78
+ if (chunk.id !== digest || !Number.isSafeInteger(size) || size < 0 || size > 8 * 1024 * 1024 || chunk.size !== size || chunk.cursor !== cursor || chunk.next_cursor !== cursor + data.length || chunk.next_cursor > size || (!data.length && !chunk.eof)) throw new CliError("Invalid file transfer response.", undefined, undefined, "transfer_unconfirmed");
79
+ chunks.push(data); cursor = chunk.next_cursor;
80
+ if (chunk.eof) break;
81
+ }
82
+ const data = Buffer.concat(chunks);
83
+ if (data.length !== size || createHash("sha256").update(data).digest("hex") !== digest) throw new CliError("Download checksum mismatch. No destination file was written.", undefined, undefined, "checksum_mismatch");
84
+ const destination = resolve(file), temporary = join(dirname(destination), `.nomac-download-${randomBytes(12).toString("hex")}`);
85
+ writeFileSync(temporary, data, { flag: "wx", mode: 0o600 });
86
+ try { linkSync(temporary, destination); } finally { unlinkSync(temporary); }
87
+ result = { id: digest, size: data.length, file: destination, complete: true };
88
+ } else throw new CliError(`Unknown transport command: ${command}`);
89
+ print(JSON.stringify(result));
90
+ return result;
91
+ }
package/src/upload.mjs CHANGED
@@ -9,7 +9,7 @@ export async function uploadSourceSnapshot(projectId, tarball) {
9
9
  } catch (error) {
10
10
  // Older servers have no grant route. Outages and authorization failures
11
11
  // should remain actionable, not silently retry through another transport.
12
- if (!(error instanceof CliError) || error.status !== 404 || error.code) throw error;
12
+ if (!(error instanceof CliError) || error.status !== 404 || (error.code && error.code !== "bad_response")) throw error;
13
13
  if (tarball.length > 4 * 1024 * 1024) {
14
14
  throw new CliError("direct uploads unavailable", "This server does not support direct source uploads. Update the server or reduce the compressed archive to 4 MiB.");
15
15
  }