@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.
package/src/mcp.mjs CHANGED
@@ -11,6 +11,7 @@ import { loadProjectState, saveProjectState } from "./config.mjs";
11
11
  import { packTarball } from "./pack.mjs";
12
12
  import { uploadSourceSnapshot } from "./upload.mjs";
13
13
  import { uploadStoreAsset } from "./store-upload.mjs";
14
+ import { MAC_TOOLS } from "./mac-tools.mjs";
14
15
 
15
16
  const PROTOCOL_FALLBACK = "2025-06-18";
16
17
 
@@ -27,6 +28,32 @@ async function resolveProjectId(args) {
27
28
  }
28
29
 
29
30
  export const TOOLS = [
31
+ ...MAC_TOOLS,
32
+ {
33
+ name: "check_project", description: "Free source-readiness check of the latest uploaded snapshot. No Apple credentials or paid compute. Run push_project first or after code changes; this does not prove compilation or TestFlight delivery.",
34
+ inputSchema: { type: "object", properties: { project_id: { type: "string" } }, additionalProperties: false },
35
+ handler: async args => text(await api("POST", "/api/v1/check-project", { body: { project_id: await resolveProjectId(args) } })),
36
+ },
37
+ {
38
+ name: "get_billing_status", description: "Read confirmed payment, contracted prices, quota/reset, renewal and first-purchase refund eligibility. After payment, resume a blocked build once with a fresh request_key only if the earlier response said build_started:false. Reconcile unknown outcomes first.",
39
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
40
+ handler: async () => text(await api("GET", "/api/v1/billing/status")),
41
+ },
42
+ {
43
+ name: "get_checkout_link", description: "Return an org-bound checkout link; the human chooses a plan and pays explicitly. This tool does not charge them. Reuses pending checkout; existing subscribers get billing management instead.",
44
+ inputSchema: { type: "object", properties: { plan: { type: "string", enum: ["starter", "pro"] } }, additionalProperties: false },
45
+ handler: async args => text(await api("POST", "/api/v1/billing/checkout", { body: args })),
46
+ },
47
+ {
48
+ name: "request_refund", description: "Preview the first-purchase refund amount and subscription cancellation. Default confirm:false. Present the quote and effect to the customer; only after explicit authorization for both actions call with confirm:true and its request_id. A build failure alone does not authorize a refund. Reason is optional.",
49
+ inputSchema: { type: "object", properties: { confirm: { type: "boolean", default: false }, request_id: { type: "string" }, reason: { type: "string", maxLength: 1000 } }, additionalProperties: false },
50
+ handler: async args => text(await api("POST", "/api/v1/billing/refund", { body: args })),
51
+ },
52
+ {
53
+ name: "get_refund_status", description: "Read the saved refund and cancellation progress. Does not submit a refund or cancel anything.",
54
+ inputSchema: { type: "object", properties: { request_id: { type: "string" } }, required: ["request_id"], additionalProperties: false },
55
+ handler: async args => text(await api("GET", `/api/v1/billing/refund?request_id=${encodeURIComponent(args.request_id)}`)),
56
+ },
30
57
  {
31
58
  name: "upload_store_asset",
32
59
  description: "Upload a new Apple screenshot, preview, purchase image, supplemental reviewer attachment or encryption document from file_path (up to 500 MiB, subject to Apple limits) or data_base64 (2 MiB). Discover the createInstance operation/body and parent scope with get_store_capabilities/read_store. Preview by default; confirm:true reserves, uploads and commits. Preserve request_key and identical bytes/body to resume. ready:true requires Apple processing COMPLETE. Message attachments require App Store Connect.",
@@ -114,6 +141,7 @@ export const TOOLS = [
114
141
  properties: {
115
142
  project_id: { type: "string" },
116
143
  workflow: { type: "string", enum: ["release", "smoke"], default: "release" },
144
+ request_key: { type: "string", description: "Reuse this key to reconcile an unknown response. Use a fresh key after a confirmed build_started:false and payment/setup completion." },
117
145
  },
118
146
  additionalProperties: false,
119
147
  },
@@ -121,7 +149,7 @@ export const TOOLS = [
121
149
  const project_id = await resolveProjectId(args);
122
150
  const res = await api("POST", "/api/v1/builds", {
123
151
  body: { project_id, workflow: args?.workflow ?? "release" },
124
- idempotencyKey: `mcp-${Date.now()}-${Math.random().toString(36).slice(2)}`,
152
+ idempotencyKey: args?.request_key ?? `mcp-${randomUUID()}`,
125
153
  });
126
154
  let warning;
127
155
  try {
@@ -335,12 +363,14 @@ export const TOOLS = [
335
363
  {
336
364
  name: "report_issue",
337
365
  description:
338
- "File an unknown/unfixable error with nomac support. Auto-attaches build error, logs (redacted) and lint state when you pass ref_id (a bld_… id). Deduped by error fingerprint. An optional email requests a direct resolution update; replies are always available by calling with no description.",
366
+ "File an unknown/unfixable error with nomac support. For a Mac use session_id and optional job_id; for a build use ref_id (bld_…). Attaches account-scoped context and deduplicates repeats. An optional email requests a direct resolution update; replies are always available by calling with no description.",
339
367
  inputSchema: {
340
368
  type: "object",
341
369
  properties: {
342
370
  description: { type: "string", description: "what went wrong + what you tried (≥10 chars). Omit to read existing tickets/replies." },
343
371
  ref_id: { type: "string" },
372
+ session_id: { type: "string", pattern: "^ses_[A-Za-z0-9_-]{1,80}$" },
373
+ job_id: { type: "string", pattern: "^job_[a-f0-9]{40}$" },
344
374
  email: {
345
375
  type: "string",
346
376
  format: "email",
@@ -400,7 +430,7 @@ export async function runMcpServer() {
400
430
  reply(await tool.handler(params?.arguments ?? {}));
401
431
  } catch (err) {
402
432
  reply({
403
- content: [{ type: "text", text: err?.friendly ?? err?.message ?? String(err) }],
433
+ content: [{ type: "text", text: err?.details ? JSON.stringify({ error: { ...err.details, message: err.friendly ?? err.message } }, null, 2) : err?.friendly ?? err?.message ?? String(err) }],
404
434
  isError: true,
405
435
  });
406
436
  }
@@ -0,0 +1,135 @@
1
+ import { spawn } from "node:child_process";
2
+ import { isAbsolute } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { CliError } from "./api.mjs";
5
+
6
+ export const shellQuote = (value) => `'${String(value).replaceAll("'", "'\\''")}'`;
7
+
8
+ export function validateTarget(target) {
9
+ // A hostname or user-owned SSH config alias, never command-line options or a shell expression.
10
+ if (typeof target !== "string" || !/^(?:[a-zA-Z0-9_][a-zA-Z0-9_.-]*@)?[a-zA-Z0-9_][a-zA-Z0-9_.-]*$/.test(target)) {
11
+ throw new CliError("invalid SSH target", "Use --host user@hostname or an alias from your SSH config. For IPv6, configure an SSH alias first.", undefined, "invalid_ssh_target");
12
+ }
13
+ return target;
14
+ }
15
+
16
+ export function validateWorkspace(path) {
17
+ if (typeof path !== "string" || !path.startsWith("/") || path === "/" || /[\x00-\x1f\x7f]/.test(path) || path.split("/").some(part => part === ".." || part === ".")) {
18
+ throw new CliError("invalid workspace", "The Mac returned an invalid workspace path.");
19
+ }
20
+ return path;
21
+ }
22
+
23
+ export function sshArgs(session, { batch = true } = {}) {
24
+ validateTarget(session.target);
25
+ if (session.port !== undefined && (!Number.isInteger(session.port) || session.port < 1 || session.port > 65535)) throw new CliError("invalid SSH port");
26
+ if (session.identity && (!isAbsolute(session.identity) || /[\x00-\x1f\x7f]/.test(session.identity))) throw new CliError("invalid identity path");
27
+ if (session.mode === "hosted") {
28
+ if (!/^ses_[A-Za-z0-9_-]{1,80}$/.test(session.id) || !isAbsolute(session.knownHosts ?? "") || /[\x00-\x1f\x7f]/.test(session.knownHosts)) throw new CliError("invalid hosted SSH state");
29
+ }
30
+ return [
31
+ ...(session.mode === "hosted" ? ["-F", "/dev/null", "-o", `UserKnownHostsFile=${session.knownHosts}`, "-o", "GlobalKnownHostsFile=/dev/null",
32
+ "-o", `HostKeyAlias=nomac-${session.id}`, "-o", `ProxyCommand=${[process.execPath, fileURLToPath(new URL("../bin/nomac.mjs", import.meta.url)), "tunnel", session.id].map(shellQuote).join(" ")}`] : []),
33
+ "-o", "StrictHostKeyChecking=yes", "-o", "ForwardAgent=no",
34
+ "-o", "ConnectTimeout=15", "-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=3",
35
+ // Avoid reusing a separately owned master connection with different authentication/forwarding.
36
+ "-o", "ControlMaster=no", "-o", "ControlPath=none",
37
+ ...(batch ? ["-o", "BatchMode=yes", "-o", "ClearAllForwardings=yes"] : []),
38
+ ...(session.port ? ["-p", String(session.port)] : []),
39
+ ...(session.identity ? ["-i", session.identity, "-o", "IdentitiesOnly=yes"] : []),
40
+ ];
41
+ }
42
+
43
+ export function runProcess(program, args, { input, inherit = false, maxBytes = 8 * 1024 * 1024, timeoutMs, env } = {}) {
44
+ return new Promise((resolve, reject) => {
45
+ const child = spawn(program, args, { shell: false, stdio: inherit ? "inherit" : ["pipe", "pipe", "pipe"], ...(env ? { env } : {}) });
46
+ const output = [], errors = [];
47
+ let bytes = 0, failure, timer;
48
+ const stop = (message) => { failure = new CliError(message); child.kill("SIGTERM"); };
49
+ if (timeoutMs) timer = setTimeout(() => stop("Remote operation timed out; inspect its outcome before retrying."), timeoutMs);
50
+ if (!inherit) {
51
+ for (const [stream, chunks] of [[child.stdout, output], [child.stderr, errors]]) {
52
+ stream.on("data", chunk => {
53
+ bytes += chunk.length;
54
+ if (bytes > maxBytes) stop("Command output exceeded the capture limit. Run the command through nomac ssh to stream its output.");
55
+ else chunks.push(chunk);
56
+ });
57
+ }
58
+ child.stdin.on("error", error => { if (error.code !== "EPIPE") failure = error; });
59
+ child.stdin.end(input);
60
+ }
61
+ child.on("error", error => {
62
+ clearTimeout(timer);
63
+ reject(new CliError(error.message, error.code === "ENOENT" ? `${program} is required but was not found in PATH.` : error.message, undefined, error.code === "ENOENT" ? "tool_missing" : "process_failed"));
64
+ });
65
+ child.on("close", (code, signal) => {
66
+ clearTimeout(timer);
67
+ if (failure) return reject(failure);
68
+ const result = { code: code ?? 1, signal, stdout: Buffer.concat(output).toString("utf8"), stderr: Buffer.concat(errors).toString("utf8") };
69
+ if (code !== 0) {
70
+ const error = new CliError(`${program} exited ${code ?? signal}`, result.stderr.trim() || `${program} exited ${code ?? signal}`, undefined, program === "ssh" ? "ssh_failed" : "transfer_failed");
71
+ error.exitCode = code && code > 0 ? code : 1;
72
+ error.result = result;
73
+ return reject(error);
74
+ }
75
+ resolve(result);
76
+ });
77
+ });
78
+ }
79
+
80
+ export function remote(session, script, { run = runProcess, ...options } = {}) {
81
+ return run("ssh", [...sshArgs(session), session.target, script], { timeoutMs: 60_000, ...options });
82
+ }
83
+
84
+ /** Does not install a project pipeline or run source code. Creates only this owned workspace. */
85
+ export async function prepareWorkspace(session, options) {
86
+ if (!/^nms_[a-f0-9]{32}$/.test(session.id)) throw new CliError("invalid session id");
87
+ const location = session.workspace
88
+ ? shellQuote(validateWorkspace(session.workspace))
89
+ : `"$HOME/.nomac/workspaces/${session.id}"`;
90
+ const script = [
91
+ 'test "$(uname -s)" = Darwin || { echo "Nomac needs a macOS host." >&2; exit 1; }',
92
+ "command -v rsync >/dev/null || { echo 'Install rsync on the Mac before using file transfer.' >&2; exit 1; }",
93
+ "perl -MJSON::PP -MDigest::SHA -MEncode -MFcntl -e 'exit 0' || { echo 'Perl checksum and JSON modules are required for file conflict checks.' >&2; exit 1; }",
94
+ `umask 077; mkdir -p ${location} && cd ${location} || exit 1`,
95
+ "pwd -P", "sw_vers -productVersion", "uname -m",
96
+ 'if [ "$(id -u)" = 0 ]; then echo admin; else id -Gn; fi', "df -Pk . | tail -1 | awk '{print $4}'",
97
+ ].join("\n");
98
+ const { stdout } = await remote(session, script, options);
99
+ const lines = stdout.trimEnd().split("\n");
100
+ if (lines.length !== 5) throw new CliError("unexpected Mac response", "The SSH shell printed unexpected output. Ensure non-interactive startup files are quiet, then retry nomac start.");
101
+ const [workspace, osVersion, architecture, groups, availableKb] = lines;
102
+ validateWorkspace(workspace);
103
+ if (!/^[0-9]+(?:\.[0-9]+)*$/.test(osVersion) || !/^[a-zA-Z0-9_]+$/.test(architecture) || !/^\d+$/.test(availableKb)) throw new CliError("invalid Mac capabilities");
104
+ const admin = groups.split(/\s+/).includes("admin");
105
+ if (!admin) throw new CliError("administrator account required", "This account is not a macOS administrator. Use an administrator account for a full Nomac workspace.");
106
+ return { workspace, capabilities: { os: "macOS", version: osVersion, architecture, administrator: admin, available_disk_bytes: Number(availableKb) * 1024 } };
107
+ }
108
+
109
+ export function workspaceCommand(session, args = []) {
110
+ const command = args.length ? args.map(shellQuote).join(" ") : '"$SHELL" -l';
111
+ return `cd ${shellQuote(validateWorkspace(session.workspace))} && exec ${command}`;
112
+ }
113
+
114
+ export function forwardingArg(value) {
115
+ const match = /^(\d{1,5}):(\d{1,5})$/.exec(value);
116
+ if (!match || match.slice(1).some(port => Number(port) < 1 || Number(port) > 65535)) throw new CliError("invalid port mapping", "Use --forward LOCAL_PORT:REMOTE_PORT, for example --forward 8080:3000.");
117
+ return `127.0.0.1:${Number(match[1])}:127.0.0.1:${Number(match[2])}`;
118
+ }
119
+
120
+ /** rsync has its own argument parser, not a POSIX shell: quoted apostrophes double. */
121
+ export const rsyncQuote = (value) => `'${String(value).replaceAll("'", "''")}'`;
122
+
123
+ export function rsyncServerCommand(session, argv, from = ".") {
124
+ const index = argv.findIndex((arg, i) => arg === "rsync" && argv[i + 1] === "--server");
125
+ if (index === -1) throw new CliError("invalid rsync server command");
126
+ const source = String(from);
127
+ if (source.startsWith("/") || source.split("/").includes("..") || /[\x00-\x1f\x7f\\]/.test(source)) throw new CliError("invalid transfer source");
128
+ return [
129
+ `cd ${shellQuote(validateWorkspace(session.workspace))} || exit 1`,
130
+ `p=${shellQuote(source === "." ? "." : `./${source}`)}`,
131
+ 'while [ "$p" != . ]; do test ! -L "$p" || exit 1; p=$(dirname "$p"); done',
132
+ `cd ${shellQuote(source)} || exit 1`,
133
+ `exec ${argv.slice(index).map(shellQuote).join(" ")}`,
134
+ ].join("\n");
135
+ }
@@ -0,0 +1,45 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { CliError } from "./api.mjs";
6
+
7
+ /** Session metadata stays outside the project: a checkout cannot choose an SSH target. */
8
+ export function sessionStore(projectDir = process.cwd(), stateDir = join(homedir(), ".nomac", "sessions")) {
9
+ const project = realpathSync(projectDir);
10
+ const name = createHash("sha256").update(project).digest("hex").slice(0, 24);
11
+ const directory = join(stateDir, name);
12
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
13
+ chmodSync(directory, 0o700);
14
+ const stateFile = join(directory, "session.json");
15
+ const baselineFile = join(directory, "baseline.json");
16
+ const read = (file) => {
17
+ if (!existsSync(file)) return null;
18
+ try { return JSON.parse(readFileSync(file, "utf8")); }
19
+ catch { throw new CliError("invalid session state", `Cannot read ${file}. Restore it before continuing; no remote operation was started.`); }
20
+ };
21
+ const write = (file, data) => {
22
+ const temporary = `${file}.${randomUUID()}.tmp`;
23
+ try {
24
+ writeFileSync(temporary, JSON.stringify(data, null, 2) + "\n", { mode: 0o600, flag: "wx" });
25
+ renameSync(temporary, file);
26
+ } finally { rmSync(temporary, { force: true }); }
27
+ };
28
+ return {
29
+ project, directory,
30
+ load: () => read(stateFile),
31
+ save: (value) => write(stateFile, value),
32
+ baseline: () => read(baselineFile) ?? {},
33
+ saveBaseline: (value) => write(baselineFile, value),
34
+ async locked(operation) {
35
+ const lock = join(directory, "operation.lock");
36
+ try { mkdirSync(lock, { mode: 0o700 }); }
37
+ catch (error) {
38
+ if (error.code !== "EEXIST") throw error;
39
+ throw new CliError("session busy", `Another session operation is running. If it was interrupted, verify it has ended before removing ${lock}.`);
40
+ }
41
+ try { return await operation(); }
42
+ finally { rmSync(lock, { recursive: true, force: true }); }
43
+ },
44
+ };
45
+ }
@@ -0,0 +1,215 @@
1
+ import { createHash, randomUUID } from "node:crypto";
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";
4
+ import { fileURLToPath } from "node:url";
5
+ import ignoreFactory from "ignore";
6
+ import { CliError } from "./api.mjs";
7
+ import { remote, rsyncQuote, runProcess, shellQuote, validateWorkspace } from "./session-ssh.mjs";
8
+
9
+ const EXCLUDED = [
10
+ ".git/", ".nomac/", ".nomac.json", ".nomac-transfer/", "node_modules/", "Pods/", "build/", "dist/", "DerivedData/", ".build/", ".next/",
11
+ ".env", ".env.*", "*.p8", "*.p12", "*.pem", "*.key", "*.mobileprovision", ".ssh/", ".aws/", ".netrc", ".npmrc", "id_rsa*", "id_ed25519*", ".DS_Store",
12
+ ];
13
+
14
+ export function projectFiles(root) {
15
+ const denied = ignoreFactory().add(EXCLUDED);
16
+ const files = [], skippedLinks = [];
17
+ const walk = (directory, inherited = []) => {
18
+ const scopes = [...inherited];
19
+ for (const name of [".gitignore", ".nomacignore"]) {
20
+ const path = join(directory, name);
21
+ // Ignore files themselves must not read through a symlink outside the project.
22
+ if (existsSync(path) && lstatSync(path).isFile()) scopes.push({ root: directory, rules: ignoreFactory().add(readFileSync(path, "utf8")) });
23
+ }
24
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
25
+ const path = join(directory, entry.name), rel = relative(root, path).split(sep).join("/");
26
+ const candidate = rel + (entry.isDirectory() ? "/" : "");
27
+ if (denied.ignores(candidate)) continue;
28
+ let ignored = false;
29
+ for (const scope of scopes) {
30
+ const result = scope.rules.test(relative(scope.root, path).split(sep).join("/") + (entry.isDirectory() ? "/" : ""));
31
+ if (result.ignored) ignored = true;
32
+ if (result.unignored) ignored = false;
33
+ }
34
+ if (ignored) continue;
35
+ if (entry.isSymbolicLink()) skippedLinks.push(rel);
36
+ else if (entry.isDirectory()) walk(path, scopes);
37
+ else if (entry.isFile()) files.push(rel);
38
+ }
39
+ };
40
+ walk(root);
41
+ return { files: files.sort(), skippedLinks: skippedLinks.sort() };
42
+ }
43
+
44
+ export async function fingerprint(path) {
45
+ const stat = lstatSync(path);
46
+ if (!stat.isFile()) return stat.isSymbolicLink() ? "symlink" : "other";
47
+ const hash = createHash("sha256");
48
+ for await (const chunk of createReadStream(path, { flags: constants.O_RDONLY | constants.O_NOFOLLOW })) hash.update(chunk);
49
+ return `file:${hash.digest("hex")}:${(stat.mode & 0o777).toString(8)}`;
50
+ }
51
+
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.");
54
+ return posix.normalize(path || ".").replace(/\/$/, "") || ".";
55
+ }
56
+
57
+ // One process per batch, rather than launching shasum/stat for every source file.
58
+ // These Perl modules ship with the Perl used by macOS's checksum tools.
59
+ const REMOTE_FINGERPRINTS = `
60
+ use strict; use warnings;
61
+ use JSON::PP; use Digest::SHA; use Encode qw(encode_utf8);
62
+ use Fcntl qw(O_RDONLY O_NOFOLLOW);
63
+ my $json = JSON::PP->new->utf8;
64
+ my $names = $json->decode(do { local $/; <STDIN> });
65
+ die "Invalid file list" unless ref($names) eq 'ARRAY';
66
+ my %result;
67
+ for my $name (@$names) {
68
+ die "Invalid path" if ref($name) || $name =~ m{^/|(?:^|/)\\.\\.(?:/|$)|[\\x00-\\x1f\\x7f\\\\]};
69
+ my $file = './' . encode_utf8($name);
70
+ my @parts = split m{/}, $file;
71
+ my $unsafe = 0;
72
+ while (@parts > 1) {
73
+ if (-l join('/', @parts)) { $unsafe = 1; last; }
74
+ pop @parts;
75
+ }
76
+ if ($unsafe) { $result{$name} = 'symlink'; next; }
77
+ if (!-e $file) { $result{$name} = 'missing'; next; }
78
+ if (!-f $file) { $result{$name} = 'other'; next; }
79
+ sysopen(my $handle, $file, O_RDONLY | O_NOFOLLOW) or die "Cannot open source file";
80
+ binmode($handle);
81
+ my $mode = sprintf('%o', (stat($handle))[2] & 0777);
82
+ my $digest = Digest::SHA->new(256)->addfile($handle)->hexdigest;
83
+ close($handle);
84
+ $result{$name} = "file:$digest:$mode";
85
+ }
86
+ print $json->encode(\\%result);
87
+ `;
88
+
89
+ export async function remoteFingerprints(session, files, options) {
90
+ const result = Object.create(null);
91
+ for (let offset = 0; offset < files.length; offset += 1000) {
92
+ const batch = files.slice(offset, offset + 1000);
93
+ for (const path of batch) safeRelative(path);
94
+ const script = `cd ${shellQuote(validateWorkspace(session.workspace))} && perl -e ${shellQuote(REMOTE_FINGERPRINTS)}`;
95
+ const { stdout } = await remote(session, script, { ...options, input: JSON.stringify(batch) });
96
+ let values;
97
+ try { values = JSON.parse(stdout); } catch { throw new CliError("invalid remote file response"); }
98
+ if (!values || Array.isArray(values) || Object.keys(values).length !== batch.length) throw new CliError("invalid remote file response");
99
+ for (const path of batch) {
100
+ const value = Object.hasOwn(values, path) && values[path];
101
+ if (typeof value !== "string" || !/^(?:missing|symlink|other|file:[a-f0-9]{64}:[0-7]{1,4})$/.test(value)) throw new CliError("invalid remote fingerprint");
102
+ Object.defineProperty(result, path, { value, enumerable: true, configurable: true, writable: true });
103
+ }
104
+ }
105
+ return result;
106
+ }
107
+
108
+ function transferArgs() {
109
+ const bridge = fileURLToPath(new URL("../bin/rsync-ssh.mjs", import.meta.url));
110
+ return ["-rpt", "--checksum", "--itemize-changes", "--stats", "--timeout=60", "-e", [process.execPath, bridge].map(rsyncQuote).join(" ")];
111
+ }
112
+
113
+ const transferEnv = (session, remoteDirectory = ".") => ({ ...process.env, NOMAC_TRANSFER_SESSION: JSON.stringify({ ...session, remoteDirectory }) });
114
+
115
+ function conflictError(paths, side) {
116
+ const error = new CliError("transfer conflict", `${side} changes would be overwritten: ${paths.slice(0, 10).join(", ")}. Retrieve/reconcile the changes first, or explicitly use --overwrite.`);
117
+ error.code = "transfer_conflict";
118
+ error.details = { paths, side };
119
+ return error;
120
+ }
121
+
122
+ export async function syncProject(session, store, { overwrite = false, dryRun = false, run = runProcess } = {}) {
123
+ const { files, skippedLinks } = projectFiles(store.project);
124
+ if (!files.length) throw new CliError("no project files", "No files remain after .gitignore, .nomacignore and default exclusions.");
125
+ const local = Object.create(null);
126
+ for (const path of files) local[path] = await fingerprint(join(store.project, path));
127
+ const baseline = Object.assign(Object.create(null), store.baseline());
128
+ const previous = await remoteFingerprints(session, files, { run });
129
+ const unsafe = files.filter(path => ["symlink", "other"].includes(previous[path]));
130
+ if (unsafe.length) throw new CliError("unsafe remote path", `Remote paths are symlinks or have incompatible types: ${unsafe.slice(0, 10).join(", ")}. Resolve them inside the workspace first.`);
131
+ const conflicts = files.filter(path => previous[path] !== "missing" && previous[path] !== local[path] && previous[path] !== baseline[path]);
132
+ if (conflicts.length && !overwrite) throw conflictError(conflicts, "Remote");
133
+ if (dryRun) return { dry_run: true, files: files.length, changed: files.filter(path => previous[path] !== local[path]), skipped_symlinks: skippedLinks };
134
+ const output = await run("rsync", [...transferArgs(), "--from0", "--files-from=-", `${store.project}/`, `${session.target}:./`], { input: Buffer.from(files.join("\0") + "\0"), env: transferEnv(session) });
135
+ const after = await remoteFingerprints(session, files, { run });
136
+ const changedDuringTransfer = files.filter(path => after[path] !== local[path]);
137
+ for (const path of files) if (after[path] === local[path]) baseline[path] = local[path];
138
+ store.saveBaseline(baseline);
139
+ 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.");
140
+ return { files: files.length, changed: files.filter(path => previous[path] !== local[path]), skipped_symlinks: skippedLinks, output: output.stdout };
141
+ }
142
+
143
+ /** Refuse symlink ancestors, including the destination itself, before local writes. */
144
+ export function assertSafeDestination(root, target) {
145
+ const rel = relative(root, target);
146
+ if (rel.startsWith(`..${sep}`) || rel === ".." || isAbsolute(rel)) throw new CliError("destination escapes project");
147
+ let current = root;
148
+ for (const part of rel.split(sep).filter(Boolean)) {
149
+ current = join(current, part);
150
+ try { if (lstatSync(current).isSymbolicLink()) throw new CliError("unsafe local path", `Refusing to write through symlink: ${current}`); }
151
+ catch (error) { if (error.code !== "ENOENT") throw error; }
152
+ }
153
+ }
154
+
155
+ export async function pullProject(session, store, destination, { from = destination, overwrite = false, dryRun = false, run = runProcess } = {}) {
156
+ destination = safeRelative(destination); from = safeRelative(from);
157
+ const localRoot = resolve(store.project, destination);
158
+ assertSafeDestination(store.project, localRoot);
159
+ const staging = mkdtempSync(join(store.directory, "pull-"));
160
+ const backup = join(store.directory, "backups", randomUUID());
161
+ try {
162
+ // 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) });
164
+ const files = [];
165
+ const walk = (directory) => {
166
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
167
+ const path = join(directory, entry.name);
168
+ if (entry.isDirectory()) walk(path);
169
+ else if (entry.isFile()) files.push(relative(staging, path));
170
+ else throw new CliError("unsupported download entry", "The download contains a symlink or special file. Retrieve it explicitly over SSH after reviewing it.");
171
+ }
172
+ };
173
+ walk(staging);
174
+ const baseline = Object.assign(Object.create(null), store.baseline()), plan = [], conflicts = [];
175
+ for (const path of files) {
176
+ const target = join(localRoot, path), incoming = await fingerprint(join(staging, path));
177
+ assertSafeDestination(store.project, target);
178
+ const current = existsSync(target) ? await fingerprint(target) : "missing";
179
+ if (current === "other") throw new CliError("incompatible local path", `A directory or special file occupies ${target}.`);
180
+ const remotePath = from === "." ? path : `${from.replace(/\/$/, "")}/${path}`;
181
+ // A baseline is meaningful only when downloading to the original project-relative path.
182
+ const samePath = relative(store.project, target).split(sep).join("/") === remotePath.split(sep).join("/");
183
+ if (current !== "missing" && current !== incoming && (!samePath || baseline[remotePath] !== current)) conflicts.push(path);
184
+ plan.push({ path, target, current, incoming, remotePath, samePath });
185
+ }
186
+ if (conflicts.length && !overwrite) throw conflictError(conflicts, "Local");
187
+ if (dryRun) return { dry_run: true, files: files.length, changed: plan.filter(file => file.current !== file.incoming).map(file => file.path) };
188
+ let backups = false;
189
+ for (const file of plan) {
190
+ assertSafeDestination(store.project, file.target);
191
+ const current = existsSync(file.target) ? await fingerprint(file.target) : "missing";
192
+ if (current !== file.current) throw new CliError("local file changed", "A local file changed during pull. Stop concurrent edits and retry. Earlier replacements have recovery copies.");
193
+ if (current !== file.incoming) {
194
+ mkdirSync(dirname(file.target), { recursive: true });
195
+ if (current !== "missing") {
196
+ const recovery = join(backup, file.path);
197
+ mkdirSync(dirname(recovery), { recursive: true, mode: 0o700 });
198
+ copyFileSync(file.target, recovery, constants.COPYFILE_EXCL);
199
+ backups = true;
200
+ }
201
+ const temporary = join(dirname(file.target), `.nomac-download-${randomUUID()}`);
202
+ try {
203
+ const sourcePath = join(staging, file.path), stat = lstatSync(sourcePath);
204
+ copyFileSync(sourcePath, temporary, constants.COPYFILE_EXCL);
205
+ chmodSync(temporary, stat.mode & 0o777);
206
+ utimesSync(temporary, stat.atime, stat.mtime);
207
+ renameSync(temporary, file.target);
208
+ } finally { rmSync(temporary, { force: true }); }
209
+ }
210
+ if (file.samePath) baseline[file.remotePath] = file.incoming;
211
+ }
212
+ store.saveBaseline(baseline);
213
+ return { files: files.length, changed: plan.filter(file => file.current !== file.incoming).map(file => file.path), ...(backups ? { recovery_directory: backup } : {}) };
214
+ } finally { rmSync(staging, { recursive: true, force: true }); }
215
+ }
@@ -0,0 +1,144 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync, realpathSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { parseArgs } from "node:util";
5
+ import { CliError } from "./api.mjs";
6
+ import { sessionStore } from "./session-store.mjs";
7
+ import { forwardingArg, prepareWorkspace, runProcess, shellQuote, sshArgs, validateTarget, workspaceCommand } from "./session-ssh.mjs";
8
+ import { pullProject, syncProject } from "./session-transfer.mjs";
9
+
10
+ export const SESSION_HELP = `Nomac 2.0 — SSH workspace preview
11
+
12
+ First connection (use a dedicated Mac you control):
13
+ nomac start --host dev@my-mac [--identity ~/.ssh/id_ed25519] [--port 22]
14
+
15
+ Then, from the same local project:
16
+ nomac start reconnect to the same Mac workspace
17
+ nomac sync . upload changed project files
18
+ nomac sync --dry-run inspect changes before uploading
19
+ nomac ssh open a normal shell in the workspace
20
+ nomac ssh -- node --version run any command (quote shell scripts explicitly)
21
+ nomac ssh --forward 8080:3000
22
+ nomac pull ./output download a remote directory into ./output
23
+ nomac pull . bring remote source changes back, with conflict checks
24
+ nomac session status --json inspect connection, capabilities and ownership
25
+ nomac stop detach locally; the externally owned Mac stays running
26
+
27
+ Options:
28
+ sync/pull --overwrite explicitly replace conflicting files
29
+ pull --from PATH choose a different remote directory
30
+ start/sync/pull/stop --json return machine-readable results
31
+
32
+ Requires OpenSSH and rsync locally; macOS, rsync and Perl checksum/JSON modules remotely.
33
+ SSH host keys must already be verified in your known_hosts. Your SSH config/agent
34
+ can authenticate; private keys never leave this device. Passwordless sudo is not
35
+ assumed. Use .nomacignore and .gitignore for project exclusions. Symlinks are
36
+ omitted in this preview; use Git/SSH explicitly for projects that need them.
37
+
38
+ This preview connects an existing Mac. It does not rent hardware, meter charges,
39
+ revoke the owner's SSH access or shut down the Mac. Stop detaches this CLI only;
40
+ existing connections and remote processes continue. Manage rentals with their owner.`;
41
+
42
+ const commonOptions = { help: { type: "boolean" }, json: { type: "boolean" } };
43
+ const commandOptions = {
44
+ start: { host: { type: "string" }, identity: { type: "string" }, port: { type: "string" } },
45
+ sync: { overwrite: { type: "boolean" }, "dry-run": { type: "boolean" } },
46
+ pull: { overwrite: { type: "boolean" }, "dry-run": { type: "boolean" }, from: { type: "string" } },
47
+ ssh: { forward: { type: "string", multiple: true } },
48
+ stop: {}, session: {},
49
+ };
50
+
51
+ export function describeSession(session) {
52
+ return {
53
+ id: session.id, mode: "external_ssh", state: session.state,
54
+ connection: { target: session.target, port: session.port ?? null, ...(session.identity ? { identity_file: session.identity } : {}) },
55
+ workspace: session.workspace ?? null, capabilities: session.capabilities ?? null,
56
+ ssh_command: session.workspace ? ["ssh", ...sshArgs(session, { batch: false }), session.target, workspaceCommand(session)].map(shellQuote).join(" ") : null,
57
+ checked_at: session.checkedAt ?? null,
58
+ billing: { managed_by: "mac_owner", nomac_compute_charges: false, rental_end_confirmed: false },
59
+ retention: "The Mac owner controls retention. Disconnecting or detaching does not remove the workspace.",
60
+ access_revoked: false, machine_stopped: false,
61
+ };
62
+ }
63
+
64
+ function requireReady(store) {
65
+ const session = store.load();
66
+ if (!session || session.state !== "ready") throw new CliError("no active workspace", "Run nomac start first. For a new project, use nomac start --host user@mac.", undefined, "no_active_session");
67
+ if (session.mode !== "external_ssh") throw new CliError("unsupported session mode");
68
+ return session;
69
+ }
70
+
71
+ export async function sessionCommand(command, argv, { store: suppliedStore, run = runProcess, print = console.log } = {}) {
72
+ let parsed;
73
+ try { parsed = parseArgs({ args: argv, options: { ...commonOptions, ...commandOptions[command] }, allowPositionals: true }); }
74
+ catch (error) { throw new CliError("invalid session options", `${error.message}\nRun nomac ${command} --help.`); }
75
+ const { values, positionals } = parsed;
76
+ if (values.help) return print(SESSION_HELP);
77
+ const store = suppliedStore ?? sessionStore();
78
+ const output = (result, summary) => print(values.json ? JSON.stringify(result, null, 2) : summary);
79
+
80
+ if (command === "session") {
81
+ if (positionals.length !== 1 || !["status", "info"].includes(positionals[0])) throw new CliError("session usage", "Usage: nomac session status [--json]");
82
+ const session = store.load();
83
+ if (!session) throw new CliError("no workspace", "Run nomac start --host user@mac first.");
84
+ const result = describeSession(session);
85
+ return output(result, `${session.state}: ${session.target}\nWorkspace: ${session.workspace ?? "not connected"}\nLast checked: ${session.checkedAt ?? "never"}\nThis is cached status. Run nomac start to check the Mac.\nThe Mac owner controls charges and retention.`);
86
+ }
87
+
88
+ if (command === "ssh") {
89
+ if (values.json) throw new CliError("SSH is a byte stream", "Use nomac session status --json for connection metadata; SSH streams the remote command directly.");
90
+ const session = requireReady(store);
91
+ const forward = (values.forward ?? []).flatMap(value => ["-L", forwardingArg(value)]);
92
+ const terminal = !positionals.length && process.stdin.isTTY && process.stdout.isTTY;
93
+ return run("ssh", [...sshArgs(session, { batch: false }), ...(terminal ? ["-t"] : []), ...forward,
94
+ "-o", "ExitOnForwardFailure=yes", session.target, workspaceCommand(session, positionals)], { inherit: true });
95
+ }
96
+
97
+ return store.locked(async () => {
98
+ if (command === "start") {
99
+ if (positionals.length) throw new CliError("start usage", "Usage: nomac start [--host user@mac] [--identity PATH] [--port PORT]");
100
+ let session = store.load();
101
+ if (values.host && session?.state === "ready" && session.target !== values.host) throw new CliError("workspace already active", "Run nomac stop before selecting another Mac. Your existing remote files will remain on that Mac.");
102
+ const target = validateTarget(values.host ?? session?.target ?? "");
103
+ if (!session || session.target !== target) {
104
+ session = { id: `nms_${randomUUID().replaceAll("-", "")}`, mode: "external_ssh", state: "connecting", target, createdAt: new Date().toISOString() };
105
+ store.saveBaseline({});
106
+ }
107
+ if (values.port) {
108
+ if (!/^\d+$/.test(values.port) || Number(values.port) < 1 || Number(values.port) > 65535) throw new CliError("invalid SSH port");
109
+ session.port = Number(values.port);
110
+ }
111
+ if (values.identity) {
112
+ const identity = resolve(values.identity);
113
+ if (!existsSync(identity)) throw new CliError("identity missing", `SSH identity file does not exist: ${identity}`);
114
+ session.identity = realpathSync(identity);
115
+ }
116
+ session.state = "connecting";
117
+ store.save(session); // Retain the same workspace ID if the connection response is lost.
118
+ const prepared = await prepareWorkspace(session, { run });
119
+ Object.assign(session, prepared, { state: "ready", checkedAt: new Date().toISOString() });
120
+ store.save(session);
121
+ return output(describeSession(session), `Connected to macOS ${prepared.capabilities.version} on ${target}\nWorkspace: ${prepared.workspace}\nNext: nomac sync . → nomac ssh\nExisting-Mac preview: rental and retention remain managed by the Mac owner.`);
122
+ }
123
+ if (command === "stop") {
124
+ if (positionals.length) throw new CliError("stop usage", "Usage: nomac stop [--json]");
125
+ const session = store.load();
126
+ if (!session) return output({ state: "detached", machine_stopped: false, access_revoked: false }, "No workspace is attached.");
127
+ session.state = "detached";
128
+ store.save(session);
129
+ return output(describeSession(session), "Detached locally. Existing SSH connections, remote processes and files remain.\nThis does not shut down the Mac, revoke owner access or end a rental.\nRun nomac start to reconnect to the same workspace.");
130
+ }
131
+ const session = requireReady(store);
132
+ if (command === "sync") {
133
+ if (positionals.length > 1 || (positionals[0] && realpathSync(resolve(positionals[0])) !== store.project)) throw new CliError("sync project mismatch", "Run nomac sync . from the project used by nomac start.");
134
+ const result = await syncProject(session, store, { overwrite: values.overwrite, dryRun: values["dry-run"], run });
135
+ return output(result, `${result.dry_run ? "Would sync" : "Synced"} ${result.changed.length} changed files (${result.files} considered).${result.skipped_symlinks.length ? `\nSkipped symlinks: ${result.skipped_symlinks.join(", ")}` : ""}`);
136
+ }
137
+ if (command === "pull") {
138
+ if (positionals.length !== 1) throw new CliError("pull usage", "Usage: nomac pull LOCAL_DIRECTORY [--from REMOTE_DIRECTORY] [--dry-run] [--overwrite]");
139
+ const result = await pullProject(session, store, positionals[0], { from: values.from ?? positionals[0], overwrite: values.overwrite, dryRun: values["dry-run"], run });
140
+ return output(result, `${result.dry_run ? "Would retrieve" : "Retrieved"} ${result.changed.length} changed files (${result.files} considered).${result.recovery_directory ? `\nPrevious local files: ${result.recovery_directory}` : ""}`);
141
+ }
142
+ throw new CliError("unknown session command");
143
+ });
144
+ }