@nomac/cli 0.2.4 → 0.3.0-beta.1
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 +4 -1
- package/bin/rsync-ssh.mjs +13 -0
- package/package.json +6 -3
- package/skill/SKILL.md +54 -5
- package/src/api.mjs +10 -4
- package/src/cli.mjs +57 -2
- package/src/hosted-sessions.mjs +171 -0
- package/src/lifecycle-schemas.json +416 -0
- package/src/mac-credits.mjs +10 -0
- package/src/mac-tools.mjs +18 -0
- package/src/mac-transport.mjs +118 -0
- package/src/mcp.mjs +54 -2
- package/src/session-ssh.mjs +135 -0
- package/src/session-store.mjs +45 -0
- package/src/session-transfer.mjs +215 -0
- package/src/sessions.mjs +144 -0
- package/src/store-upload.mjs +127 -0
- package/src/transport-command.mjs +91 -0
- package/src/upload.mjs +1 -1
|
@@ -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
|
+
}
|
package/src/sessions.mjs
ADDED
|
@@ -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
|
+
}
|