@nomac/cli 0.7.0 → 0.9.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 +2 -1
- package/package.json +1 -1
- package/skill/SKILL.md +34 -3
- package/src/api.mjs +4 -2
- package/src/cli.mjs +70 -8
- package/src/hosted-sessions.mjs +39 -6
- package/src/mac-fetch.mjs +2 -0
- package/src/mac-tools.mjs +19 -6
- package/src/mcp-args.mjs +64 -0
- package/src/mcp.mjs +23 -6
- package/src/notarize.mjs +71 -0
- package/src/publish.mjs +76 -0
- package/src/secrets.mjs +136 -0
- package/src/session-ssh.mjs +3 -1
- package/src/session-transfer.mjs +117 -38
- package/src/sessions.mjs +2 -2
- package/src/sign.mjs +37 -0
- package/src/testflight.mjs +89 -0
package/src/notarize.mjs
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { api as defaultApi, CliError } from "./api.mjs";
|
|
4
|
+
|
|
5
|
+
export const NOTARIZE_HELP = `Usage: nomac notarize <path> [--team ISSUER_ID] [--session ses_…] [--no-wait] [--json]
|
|
6
|
+
nomac notarize --id ntz_…
|
|
7
|
+
Notarize a Developer ID-signed app, disk image or installer on this folder's Mac (path: an
|
|
8
|
+
.app, .dmg, .pkg or .zip on the Mac, relative to the synced folder). Your App Store Connect
|
|
9
|
+
key stays on NoMac's server; the Mac gets only Apple's upload credentials for this one
|
|
10
|
+
submission. Apple checks it, usually for a few minutes, then the Mac staples the ticket.
|
|
11
|
+
Sign it first with your Developer ID Application certificate (upload it at nomac.app/apple):
|
|
12
|
+
codesign --force --options runtime --timestamp --sign "Developer ID Application: …" App.app`;
|
|
13
|
+
|
|
14
|
+
const DONE = new Set(["accepted", "invalid", "rejected", "failed"]);
|
|
15
|
+
|
|
16
|
+
function describe(view) {
|
|
17
|
+
switch (view.state) {
|
|
18
|
+
case "preparing": return `Preparing ${view.path} on the Mac…`;
|
|
19
|
+
case "uploading": return "Sending it to Apple…";
|
|
20
|
+
case "in_progress": return "Apple is checking it (usually a few minutes). Keep the Mac running so the ticket can be stapled.";
|
|
21
|
+
case "stapling": return "Notarized. Stapling the ticket…";
|
|
22
|
+
case "accepted": return [view.message, ...(view.warnings ?? []).map((issue) => ` warning: ${issue.path ? `${issue.path}: ` : ""}${issue.message}`)].join("\n");
|
|
23
|
+
case "invalid": case "rejected": return [
|
|
24
|
+
`Apple did not notarize it${view.status_summary ? `: ${view.status_summary}` : "."}`,
|
|
25
|
+
...(view.issues ?? []).map((issue) => ` ${issue.severity}: ${issue.path ? `${issue.path}: ` : ""}${issue.message}`),
|
|
26
|
+
"Sign every binary with your Developer ID certificate, --options runtime and --timestamp, then notarize again.",
|
|
27
|
+
].join("\n");
|
|
28
|
+
case "failed": return [`Not notarized: ${view.error?.message ?? "the notarization failed."}`,
|
|
29
|
+
...(view.error?.needs_human?.steps ?? []).map((step, index) => ` ${index + 1}. ${step}`)].join("\n");
|
|
30
|
+
default: return view.state;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function notarizeCommand(args, { api = defaultApi, print = console.log, store } = {}) {
|
|
35
|
+
const { values, positionals } = parseArgs({ args, allowPositionals: true, strict: true, options: {
|
|
36
|
+
team: { type: "string" }, session: { type: "string" }, id: { type: "string" }, "no-wait": { type: "boolean" }, json: { type: "boolean" }, help: { type: "boolean", short: "h" },
|
|
37
|
+
} });
|
|
38
|
+
if (values.help) return print(NOTARIZE_HELP);
|
|
39
|
+
let view;
|
|
40
|
+
if (values.id) {
|
|
41
|
+
if (positionals.length) throw new CliError("notarize usage", NOTARIZE_HELP);
|
|
42
|
+
view = await api("GET", `/api/v1/notarizations/${encodeURIComponent(values.id)}`);
|
|
43
|
+
} else {
|
|
44
|
+
if (positionals.length !== 1) throw new CliError("notarize usage", NOTARIZE_HELP);
|
|
45
|
+
const session = values.session ?? store?.load()?.id;
|
|
46
|
+
if (!session || !/^ses_[A-Za-z0-9_-]{1,80}$/.test(session)) throw new CliError("no Mac", "No Mac for this folder. Start one with nomac start, build and sign the app there, or pass --session ses_….");
|
|
47
|
+
view = await api("POST", `/api/v1/mac/sessions/${encodeURIComponent(session)}/notarize`, { body: {
|
|
48
|
+
path: positionals[0], request_key: `cli-notarize-${randomUUID()}`, wait_seconds: values["no-wait"] ? 0 : 50, ...(values.team ? { issuer_id: values.team } : {}),
|
|
49
|
+
} });
|
|
50
|
+
}
|
|
51
|
+
let last = "";
|
|
52
|
+
const show = (current) => {
|
|
53
|
+
if (values.json) return;
|
|
54
|
+
const line = describe(current);
|
|
55
|
+
if (line !== last) print(line);
|
|
56
|
+
last = line;
|
|
57
|
+
};
|
|
58
|
+
show(view);
|
|
59
|
+
if (!values["no-wait"]) {
|
|
60
|
+
while (!DONE.has(view.state)) {
|
|
61
|
+
view = await api("GET", `/api/v1/notarizations/${encodeURIComponent(view.id)}?wait_seconds=50`);
|
|
62
|
+
show(view);
|
|
63
|
+
}
|
|
64
|
+
} else if (!DONE.has(view.state) && !values.json) {
|
|
65
|
+
print(`Follow it with: nomac notarize --id ${view.id}`);
|
|
66
|
+
}
|
|
67
|
+
if (values.json) print(JSON.stringify(view, null, 2));
|
|
68
|
+
// The reason is already printed; exit non-zero without saying it twice.
|
|
69
|
+
if (["invalid", "rejected", "failed"].includes(view.state)) throw Object.assign(new CliError("notarize failed", ""), { friendly: "" });
|
|
70
|
+
return view;
|
|
71
|
+
}
|
package/src/publish.mjs
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { api as defaultApi, CliError } from "./api.mjs";
|
|
4
|
+
import { testflightCommand } from "./testflight.mjs";
|
|
5
|
+
|
|
6
|
+
export const PUBLISH_HELP = `Usage: nomac publish [ipa] [--whats-new TEXT] [--confirm] [--notes TEXT] [--encryption no|yes] [--team ISSUER_ID] [--session ses_…]
|
|
7
|
+
nomac publish --project prj_… [--build bld_…] [--whats-new TEXT] [--confirm]
|
|
8
|
+
Submit your app for App Review, with your App Store Connect key kept on NoMac's server.
|
|
9
|
+
With an .ipa on this folder's Mac, NoMac sends it first, as nomac testflight does, and waits
|
|
10
|
+
until Apple has processed it. Without one, --project (and --build) name a build already on
|
|
11
|
+
TestFlight; nomac testflight prints both.
|
|
12
|
+
Without --confirm nothing goes to App Review: NoMac stages the version with its build and
|
|
13
|
+
lists every blocker Apple reports. Run it again with --confirm to submit.
|
|
14
|
+
--whats-new TEXT What's New in This Version; Apple requires it for every update
|
|
15
|
+
--force submit despite red readiness findings (not recommended)`;
|
|
16
|
+
|
|
17
|
+
/** Apple answers some writes with "pending"; the same key observes the original request. */
|
|
18
|
+
async function settled(api, method, path, body, sleep) {
|
|
19
|
+
const idempotencyKey = `cli-${path.split("/").at(-1)}-${randomUUID()}`;
|
|
20
|
+
for (let attempt = 0; ; attempt++) {
|
|
21
|
+
const result = await api(method, path, { body, idempotencyKey });
|
|
22
|
+
if (result?.state !== "pending" || attempt >= 24) return result;
|
|
23
|
+
await sleep(5000);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function blockers(details) {
|
|
28
|
+
return [
|
|
29
|
+
...(details?.blockers ?? []).map((item) => ` ${item.code}: ${item.detail}`),
|
|
30
|
+
...(details?.findings ?? []).map((item) => ` ${item.title}: ${item.fix_hint}`),
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function publishCommand(args, { api = defaultApi, print = console.log, store, sleep = (ms) => new Promise((done) => setTimeout(done, ms)) } = {}) {
|
|
35
|
+
const { values, positionals } = parseArgs({ args, allowPositionals: true, strict: true, options: {
|
|
36
|
+
project: { type: "string" }, build: { type: "string" }, "whats-new": { type: "string" }, confirm: { type: "boolean" }, force: { type: "boolean" },
|
|
37
|
+
notes: { type: "string" }, encryption: { type: "string" }, team: { type: "string" }, session: { type: "string" },
|
|
38
|
+
json: { type: "boolean" }, help: { type: "boolean", short: "h" },
|
|
39
|
+
} });
|
|
40
|
+
if (values.help) return print(PUBLISH_HELP);
|
|
41
|
+
if (positionals.length > 1 || (positionals.length && (values.project || values.build)) || (!positionals.length && !values.project)) throw new CliError("publish usage", PUBLISH_HELP);
|
|
42
|
+
let project = values.project, build = values.build, version;
|
|
43
|
+
if (positionals.length) {
|
|
44
|
+
const sendArgs = [positionals[0], ...["notes", "encryption", "team", "session"].flatMap((name) => values[name] ? [`--${name}`, values[name]] : [])];
|
|
45
|
+
const sent = await testflightCommand(sendArgs, { api, print: values.json ? () => {} : print, store });
|
|
46
|
+
({ project_id: project, build_id: build } = sent);
|
|
47
|
+
version = sent.app.version;
|
|
48
|
+
}
|
|
49
|
+
if (values["whats-new"]) {
|
|
50
|
+
await settled(api, "PUT", `/api/v1/projects/${encodeURIComponent(project)}/metadata`, { ...(version ? { version_string: version } : {}), fields: { whats_new: values["whats-new"] } }, sleep);
|
|
51
|
+
if (!values.json) print("What's New is set.");
|
|
52
|
+
}
|
|
53
|
+
let result;
|
|
54
|
+
try {
|
|
55
|
+
result = await settled(api, "POST", "/api/v1/publish", { project_id: project, ...(build ? { build_id: build } : {}), confirm: Boolean(values.confirm), force: Boolean(values.force) }, sleep);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (error instanceof CliError && !values.json) {
|
|
58
|
+
const lines = blockers(error.details);
|
|
59
|
+
if (lines.length) error.friendly = [error.friendly, "Apple or the readiness check reports:", ...lines].join("\n");
|
|
60
|
+
}
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
if (values.json) { print(JSON.stringify(result, null, 2)); return result; }
|
|
64
|
+
if (result.state === "pending") {
|
|
65
|
+
print("Apple has not confirmed yet. Check with nomac publish again in a minute; nothing is sent twice.");
|
|
66
|
+
} else if (result.staged) {
|
|
67
|
+
print([
|
|
68
|
+
`Staged for App Review: build ${result.build_id}. Nothing was sent to App Review.`,
|
|
69
|
+
...(result.lint_note ? [`Note: ${result.lint_note}`] : []),
|
|
70
|
+
`To submit: nomac publish --project ${project} --build ${result.build_id} --confirm`,
|
|
71
|
+
].join("\n"));
|
|
72
|
+
} else {
|
|
73
|
+
print(`Submitted to App Review (${result.state}). Apple usually decides within one to three days.`);
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
76
|
+
}
|
package/src/secrets.mjs
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { api as defaultApi, CliError } from "./api.mjs";
|
|
4
|
+
|
|
5
|
+
export const SECRETS_HELP = `Usage: nomac secrets [command]
|
|
6
|
+
Secrets are variables and files every Mac you start receives. Values never
|
|
7
|
+
come back from NoMac, and are never taken as command-line arguments.
|
|
8
|
+
|
|
9
|
+
nomac secrets list them (names, where they go, sizes)
|
|
10
|
+
nomac secrets set NAME a variable; the value from stdin, hidden when typed
|
|
11
|
+
nomac secrets set NAME --file PATH a file, up to 1 MiB [--destination ~/.netrc]
|
|
12
|
+
nomac secrets set --from-env-file .env every NAME=value line as a variable
|
|
13
|
+
--on-request only when asked (install NAME), not on every Mac
|
|
14
|
+
nomac secrets rm NAME delete it
|
|
15
|
+
nomac secrets install [NAME...] put them on this folder's Mac now [--session ses_…]
|
|
16
|
+
nomac secrets request NAME --description TEXT [--kind file] [--destination ~/x]
|
|
17
|
+
a link where someone enters it in the browser
|
|
18
|
+
Add --json for JSON.`;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Lines of a .env file as variables, as the dashboard's import reads them
|
|
22
|
+
* (apps/web/lib/env-file.ts): comments, blank lines and `export ` are handled,
|
|
23
|
+
* quotes keep a # inside, and a value that spans lines is skipped.
|
|
24
|
+
*/
|
|
25
|
+
export function parseEnv(text) {
|
|
26
|
+
const variables = [], skipped = [];
|
|
27
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
28
|
+
const line = raw.trim();
|
|
29
|
+
if (!line || line.startsWith("#")) continue;
|
|
30
|
+
const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
31
|
+
if (!match) { skipped.push(line.slice(0, 40)); continue; }
|
|
32
|
+
let value = match[2];
|
|
33
|
+
const quote = value[0];
|
|
34
|
+
if ((quote === '"' || quote === "'") && value.length >= 2 && value.endsWith(quote)) value = value.slice(1, -1);
|
|
35
|
+
else if (quote === '"' || quote === "'") { skipped.push(match[1]); continue; }
|
|
36
|
+
else value = value.replace(/\s+#.*$/, "");
|
|
37
|
+
variables.push({ name: match[1], value });
|
|
38
|
+
}
|
|
39
|
+
return { variables, skipped };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A value from stdin: typed with echo off at a terminal, or piped (one trailing newline dropped). */
|
|
43
|
+
async function readValue(stdin, write) {
|
|
44
|
+
if (stdin.isTTY) {
|
|
45
|
+
write("Value (hidden): ");
|
|
46
|
+
stdin.setRawMode(true); stdin.resume(); stdin.setEncoding("utf8");
|
|
47
|
+
try {
|
|
48
|
+
return await new Promise((resolve, reject) => {
|
|
49
|
+
let value = "";
|
|
50
|
+
const onData = (chunk) => {
|
|
51
|
+
for (const char of chunk) {
|
|
52
|
+
if (char === "\r" || char === "\n") { stdin.off("data", onData); write("\n"); return resolve(value); }
|
|
53
|
+
if (char === "\u0003") { stdin.off("data", onData); write("\n"); return reject(new CliError("cancelled", "Cancelled.")); }
|
|
54
|
+
if (char === "\u007f") value = value.slice(0, -1);
|
|
55
|
+
else value += char;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
stdin.on("data", onData);
|
|
59
|
+
});
|
|
60
|
+
} finally { stdin.setRawMode(false); stdin.pause(); }
|
|
61
|
+
}
|
|
62
|
+
const chunks = [];
|
|
63
|
+
for await (const chunk of stdin) chunks.push(Buffer.from(chunk));
|
|
64
|
+
return Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const size = (bytes) => bytes < 1024 ? `${bytes} B` : bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
68
|
+
|
|
69
|
+
export async function secretsCommand(args, { api = defaultApi, print = console.log, write = (text) => process.stderr.write(text), stdin = process.stdin, store } = {}) {
|
|
70
|
+
const { values, positionals } = parseArgs({ args, allowPositionals: true, strict: true, options: {
|
|
71
|
+
file: { type: "string" }, "from-env-file": { type: "string" }, destination: { type: "string" }, "on-request": { type: "boolean" },
|
|
72
|
+
session: { type: "string" }, description: { type: "string" }, kind: { type: "string" }, json: { type: "boolean" }, help: { type: "boolean", short: "h" },
|
|
73
|
+
} });
|
|
74
|
+
const [command = "list", ...names] = positionals;
|
|
75
|
+
if (values.help || command === "help") return print(SECRETS_HELP);
|
|
76
|
+
const out = (result, text) => { print(values.json ? JSON.stringify(result, null, 2) : text); return result; };
|
|
77
|
+
const delivery = values["on-request"] ? "on_request" : "every_mac";
|
|
78
|
+
const path = (name) => `/api/v1/secrets/${encodeURIComponent(name)}`;
|
|
79
|
+
|
|
80
|
+
if (command === "list" || command === "ls") {
|
|
81
|
+
const result = await api("GET", "/api/v1/secrets");
|
|
82
|
+
const lines = result.secrets.map((secret) => `${secret.name} ${secret.kind === "variable" ? `$${secret.variable}` : secret.path} ${size(secret.size)} ${secret.delivery === "every_mac" ? "every Mac" : "when asked"}`);
|
|
83
|
+
const waiting = result.pending_requests.map((request) => `${request.name} requested by an agent, not entered yet`);
|
|
84
|
+
return out(result, [...lines, ...waiting].join("\n") || "No secrets yet. Add one with nomac secrets set NAME, or on nomac.app/secrets.");
|
|
85
|
+
}
|
|
86
|
+
if (command === "set") {
|
|
87
|
+
if (values["from-env-file"]) {
|
|
88
|
+
if (names.length || values.file) throw new CliError("set usage", "--from-env-file takes no NAME and no --file.");
|
|
89
|
+
const { variables, skipped } = parseEnv(readFileSync(values["from-env-file"], "utf8"));
|
|
90
|
+
const saved = [], failed = [];
|
|
91
|
+
for (const variable of variables) {
|
|
92
|
+
try { await api("PUT", path(variable.name), { body: { kind: "variable", value: variable.value, delivery } }); saved.push(variable.name); }
|
|
93
|
+
catch (error) { failed.push({ name: variable.name, message: error.friendly ?? error.message }); }
|
|
94
|
+
}
|
|
95
|
+
const result = { saved, failed, skipped };
|
|
96
|
+
out(result, [`Saved ${saved.length} of ${variables.length} variables.`, ...failed.map((item) => `${item.name}: ${item.message}`),
|
|
97
|
+
...(skipped.length ? [`Skipped lines that are not NAME=value on one line: ${skipped.join(", ")}`] : [])].join("\n"));
|
|
98
|
+
if (failed.length) process.exitCode = 1;
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
if (names.length !== 1) throw new CliError("set usage", SECRETS_HELP);
|
|
102
|
+
let body;
|
|
103
|
+
if (values.file) {
|
|
104
|
+
if (statSync(values.file).size > 1024 * 1024) throw new CliError("file too large", "A file secret holds at most 1 MiB.");
|
|
105
|
+
body = { kind: "file", value_base64: readFileSync(values.file).toString("base64"), destination: values.destination ?? null, delivery };
|
|
106
|
+
} else {
|
|
107
|
+
if (values.destination) throw new CliError("set usage", "--destination is for files: add --file PATH.");
|
|
108
|
+
body = { kind: "variable", value: await readValue(stdin, write), delivery };
|
|
109
|
+
}
|
|
110
|
+
const result = await api("PUT", path(names[0]), { body });
|
|
111
|
+
return out(result, `Saved ${result.secret.name}. New Macs receive it; run nomac secrets install to put it on a running Mac.`);
|
|
112
|
+
}
|
|
113
|
+
if (command === "rm" || command === "delete") {
|
|
114
|
+
if (names.length !== 1) throw new CliError("rm usage", "Usage: nomac secrets rm NAME");
|
|
115
|
+
const result = await api("DELETE", path(names[0]));
|
|
116
|
+
return out(result, `Deleted ${names[0]}. Macs started from now on do not receive it; a running Mac keeps its copy until it stops.`);
|
|
117
|
+
}
|
|
118
|
+
if (command === "install") {
|
|
119
|
+
const session = values.session ?? store?.load()?.id;
|
|
120
|
+
if (!session || !/^ses_[A-Za-z0-9_-]{1,80}$/.test(session)) throw new CliError("no Mac", "No Mac for this folder. Start one with nomac start, or pass --session ses_….");
|
|
121
|
+
const result = await api("POST", `/api/v1/mac/sessions/${encodeURIComponent(session)}/secrets`, { body: { names } });
|
|
122
|
+
const installed = result.credentials;
|
|
123
|
+
return out(result, [`Installed on ${session}: ${installed.state}.`,
|
|
124
|
+
...installed.secrets.map((secret) => ` ${secret.kind === "variable" ? `$${secret.name}` : secret.path}`),
|
|
125
|
+
...(installed.identities?.length ? [` ${installed.identities.length} signing identit${installed.identities.length === 1 ? "y" : "ies"}, ${installed.profiles.length} profiles`] : []),
|
|
126
|
+
...(installed.message ? [installed.message] : [])].join("\n"));
|
|
127
|
+
}
|
|
128
|
+
if (command === "request") {
|
|
129
|
+
const kind = values.kind ?? "variable";
|
|
130
|
+
if (names.length !== 1 || !values.description || !["variable", "file"].includes(kind)) throw new CliError("request usage", "Usage: nomac secrets request NAME --description TEXT [--kind file] [--destination ~/x]");
|
|
131
|
+
const result = await api("POST", "/api/v1/secrets/requests", { body: { name: names[0], kind, description: values.description,
|
|
132
|
+
...(values.destination ? { destination: values.destination } : {}), delivery } });
|
|
133
|
+
return out(result, `Open this link to enter ${result.name} (expires ${result.expires_at}):\n${result.url}`);
|
|
134
|
+
}
|
|
135
|
+
throw new CliError("unknown secrets command", SECRETS_HELP);
|
|
136
|
+
}
|
package/src/session-ssh.mjs
CHANGED
|
@@ -35,7 +35,9 @@ export function multiplexArgs(session, { platform = process.platform, home = hom
|
|
|
35
35
|
const dir = join(home, ".nomac", "cm");
|
|
36
36
|
const name = createHash("sha256").update(`${session.id}:${session.generation ?? 0}`).digest("hex").slice(0, 20);
|
|
37
37
|
const path = join(dir, name);
|
|
38
|
-
|
|
38
|
+
// macOS socket paths hold 103 bytes, and ssh first binds a temporary socket at the path
|
|
39
|
+
// plus a 17-byte suffix (".XXXXXXXXXXXXXXXX"): longer paths make ssh exit 255.
|
|
40
|
+
if (Buffer.byteLength(path) > 86) return off;
|
|
39
41
|
try { mkdirSync(dir, { recursive: true, mode: 0o700 }); chmodSync(dir, 0o700); } catch { return off; }
|
|
40
42
|
return ["-o", "ControlMaster=auto", "-o", `ControlPath=${path}`, "-o", "ControlPersist=120"];
|
|
41
43
|
}
|
package/src/session-transfer.mjs
CHANGED
|
@@ -1,49 +1,92 @@
|
|
|
1
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";
|
|
2
|
+
import { constants, copyFileSync, createReadStream, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, chmodSync, utimesSync } from "node:fs";
|
|
3
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";
|
|
7
7
|
import { remote, rsyncQuote, runProcess, shellQuote, validateWorkspace } from "./session-ssh.mjs";
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
/**
|
|
10
|
+
* What sync leaves out unless .nomacignore brings it back: folders that are
|
|
11
|
+
* rebuilt on the Mac anyway, git history, Finder metadata and NoMac's own
|
|
12
|
+
* files. Nothing is left out to protect the person from their own files:
|
|
13
|
+
* .env files, keys and certificates travel like any other file.
|
|
14
|
+
*/
|
|
15
|
+
const BUILT_IN = [
|
|
16
|
+
{ pattern: "node_modules/", reason: "rebuilt on the Mac" },
|
|
17
|
+
{ pattern: "Pods/", reason: "rebuilt on the Mac" },
|
|
18
|
+
{ pattern: "DerivedData/", reason: "rebuilt on the Mac" },
|
|
19
|
+
{ pattern: ".build/", reason: "rebuilt on the Mac" },
|
|
20
|
+
{ pattern: "build/", reason: "rebuilt on the Mac" },
|
|
21
|
+
{ pattern: ".git/", reason: "git history" },
|
|
22
|
+
{ pattern: ".DS_Store", reason: "Finder metadata" },
|
|
23
|
+
{ pattern: ".nomac/", reason: "NoMac's own files" },
|
|
24
|
+
{ pattern: ".nomac.json", reason: "NoMac's own files" },
|
|
25
|
+
{ pattern: ".nomac-transfer/", reason: "NoMac's own files" },
|
|
12
26
|
];
|
|
27
|
+
// Built-in patterns are names, matched against the entry's own name, so a folder
|
|
28
|
+
// brought back with `!` is never skipped again through its parent.
|
|
29
|
+
const builtInRules = BUILT_IN.map(({ pattern, reason }) => ({ rules: ignoreFactory().add(pattern), reason }));
|
|
13
30
|
|
|
31
|
+
/** Code-unit order, the same as Array.prototype.sort on the file lists. */
|
|
32
|
+
const byPath = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
33
|
+
|
|
34
|
+
/** The last opinion of a chain of ignore files: true (skip), false (send) or undefined (none). */
|
|
35
|
+
function verdict(scopes, path, isDirectory) {
|
|
36
|
+
let result;
|
|
37
|
+
for (const scope of scopes) {
|
|
38
|
+
const test = scope.rules.test(relative(scope.root, path).split(sep).join("/") + (isDirectory ? "/" : ""));
|
|
39
|
+
if (test.ignored) result = true;
|
|
40
|
+
if (test.unignored) result = false;
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The project as sync sends it. Every file is sent, including files a
|
|
47
|
+
* .gitignore lists (.env, local configuration, temporary files): they are
|
|
48
|
+
* small and often needed to build. Folders a .gitignore lists are skipped,
|
|
49
|
+
* because they are dependencies and build output. .nomacignore decides last:
|
|
50
|
+
* it can skip anything more, and its `!` lines bring back anything, the
|
|
51
|
+
* built-in skips included. A folder brought back is sent as it is, except
|
|
52
|
+
* for what .nomacignore itself skips. Symlinks are sent as links and never
|
|
53
|
+
* followed.
|
|
54
|
+
*/
|
|
14
55
|
export function projectFiles(root) {
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
for (const name of [".gitignore", ".nomacignore"]) {
|
|
56
|
+
const files = [], links = [], skipped = [];
|
|
57
|
+
const walk = (directory, gitScopes, nomacScopes, restored) => {
|
|
58
|
+
const git = [...gitScopes], nomac = [...nomacScopes];
|
|
59
|
+
for (const [name, list] of [[".gitignore", git], [".nomacignore", nomac]]) {
|
|
20
60
|
const path = join(directory, name);
|
|
21
61
|
// Ignore files themselves must not read through a symlink outside the project.
|
|
22
|
-
if (existsSync(path) && lstatSync(path).isFile())
|
|
62
|
+
if (existsSync(path) && lstatSync(path).isFile()) list.push({ root: directory, rules: ignoreFactory().add(readFileSync(path, "utf8")) });
|
|
23
63
|
}
|
|
24
64
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
25
65
|
const path = join(directory, entry.name), rel = relative(root, path).split(sep).join("/");
|
|
26
|
-
const candidate = rel + (
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if (result.ignored) ignored = true;
|
|
32
|
-
if (result.unignored) ignored = false;
|
|
66
|
+
const isDirectory = entry.isDirectory(), candidate = rel + (isDirectory ? "/" : "");
|
|
67
|
+
let reason;
|
|
68
|
+
if (!restored) {
|
|
69
|
+
reason = builtInRules.find(rule => rule.rules.ignores(entry.name + (isDirectory ? "/" : "")))?.reason;
|
|
70
|
+
if (!reason && isDirectory && verdict(git, path, true)) reason = "listed in .gitignore";
|
|
33
71
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
72
|
+
const decision = verdict(nomac, path, isDirectory);
|
|
73
|
+
const bringsBack = decision === false && reason !== undefined;
|
|
74
|
+
if (decision === true) reason = "listed in .nomacignore";
|
|
75
|
+
if (decision === false) reason = undefined;
|
|
76
|
+
if (reason) { skipped.push({ path: candidate, reason }); continue; }
|
|
77
|
+
if (entry.isSymbolicLink()) links.push(rel);
|
|
78
|
+
else if (isDirectory) walk(path, git, nomac, restored || bringsBack);
|
|
37
79
|
else if (entry.isFile()) files.push(rel);
|
|
38
80
|
}
|
|
39
81
|
};
|
|
40
|
-
walk(root);
|
|
41
|
-
return { files: files.sort(),
|
|
82
|
+
walk(root, [], [], false);
|
|
83
|
+
return { files: files.sort(), links: links.sort(), skipped: skipped.sort((a, b) => byPath(a.path, b.path)) };
|
|
42
84
|
}
|
|
43
85
|
|
|
44
86
|
export async function fingerprint(path) {
|
|
45
87
|
const stat = lstatSync(path);
|
|
46
|
-
if (
|
|
88
|
+
if (stat.isSymbolicLink()) return `link:${createHash("sha256").update(readlinkSync(path, { encoding: "buffer" })).digest("hex")}`;
|
|
89
|
+
if (!stat.isFile()) return "other";
|
|
47
90
|
const hash = createHash("sha256");
|
|
48
91
|
for await (const chunk of createReadStream(path, { flags: constants.O_RDONLY | constants.O_NOFOLLOW })) hash.update(chunk);
|
|
49
92
|
return `file:${hash.digest("hex")}:${(stat.mode & 0o777).toString(8)}`;
|
|
@@ -68,12 +111,21 @@ for my $name (@$names) {
|
|
|
68
111
|
die "Invalid path" if ref($name) || $name =~ m{^/|(?:^|/)\\.\\.(?:/|$)|[\\x00-\\x1f\\x7f\\\\]};
|
|
69
112
|
my $file = './' . encode_utf8($name);
|
|
70
113
|
my @parts = split m{/}, $file;
|
|
114
|
+
pop @parts;
|
|
71
115
|
my $unsafe = 0;
|
|
72
116
|
while (@parts > 1) {
|
|
73
117
|
if (-l join('/', @parts)) { $unsafe = 1; last; }
|
|
74
118
|
pop @parts;
|
|
75
119
|
}
|
|
120
|
+
# A symlinked folder above the path could send a write outside the workspace.
|
|
76
121
|
if ($unsafe) { $result{$name} = 'symlink'; next; }
|
|
122
|
+
# The path itself may be a link that sync placed: compare its target, never follow it.
|
|
123
|
+
if (-l $file) {
|
|
124
|
+
my $target = readlink($file);
|
|
125
|
+
die "Cannot read link" unless defined $target;
|
|
126
|
+
$result{$name} = 'link:' . Digest::SHA::sha256_hex($target);
|
|
127
|
+
next;
|
|
128
|
+
}
|
|
77
129
|
if (!-e $file) { $result{$name} = 'missing'; next; }
|
|
78
130
|
if (!-f $file) { $result{$name} = 'other'; next; }
|
|
79
131
|
sysopen(my $handle, $file, O_RDONLY | O_NOFOLLOW) or die "Cannot open source file";
|
|
@@ -98,7 +150,7 @@ export async function remoteFingerprints(session, files, options) {
|
|
|
98
150
|
if (!values || Array.isArray(values) || Object.keys(values).length !== batch.length) throw new CliError("invalid remote file response");
|
|
99
151
|
for (const path of batch) {
|
|
100
152
|
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");
|
|
153
|
+
if (typeof value !== "string" || !/^(?:missing|symlink|other|link:[a-f0-9]{64}|file:[a-f0-9]{64}:[0-7]{1,4})$/.test(value)) throw new CliError("invalid remote fingerprint");
|
|
102
154
|
Object.defineProperty(result, path, { value, enumerable: true, configurable: true, writable: true });
|
|
103
155
|
}
|
|
104
156
|
}
|
|
@@ -110,6 +162,30 @@ function transferArgs() {
|
|
|
110
162
|
return ["-rpt", "--checksum", "--itemize-changes", "--stats", "--timeout=60", "-e", [process.execPath, bridge].map(rsyncQuote).join(" ")];
|
|
111
163
|
}
|
|
112
164
|
|
|
165
|
+
/** Size, the biggest files, and what was skipped with the .nomacignore line that would send it. */
|
|
166
|
+
function summarize(root, { files, links, skipped }) {
|
|
167
|
+
const sizes = files.map(path => ({ path, bytes: lstatSync(join(root, path)).size }));
|
|
168
|
+
return {
|
|
169
|
+
bytes: sizes.reduce((sum, file) => sum + file.bytes, 0),
|
|
170
|
+
links: links.length,
|
|
171
|
+
biggest: sizes.sort((a, b) => b.bytes - a.bytes || byPath(a.path, b.path)).slice(0, 5),
|
|
172
|
+
skipped: skipped.map(item => ({ ...item, send_with: `!${item.path}` })),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const megabytes = bytes => `${(bytes / 1_000_000).toFixed(bytes < 10_000_000 ? 1 : 0)} MB`;
|
|
177
|
+
|
|
178
|
+
/** The human summary of a sync: what changed, how much was considered, and what stayed behind. */
|
|
179
|
+
export function describeSync(result) {
|
|
180
|
+
const lines = [`${result.dry_run ? "Would sync" : "Synced"} ${result.changed.length} changed of ${result.files} files (${megabytes(result.bytes)}${result.links ? `, ${result.links} links` : ""}).`];
|
|
181
|
+
if (result.skipped.length) {
|
|
182
|
+
lines.push("Skipped (add the line to .nomacignore to send it):");
|
|
183
|
+
for (const item of result.skipped.slice(0, 20)) lines.push(` ${item.path} ${item.reason} ${item.send_with}`);
|
|
184
|
+
if (result.skipped.length > 20) lines.push(` …and ${result.skipped.length - 20} more (--json lists all)`);
|
|
185
|
+
}
|
|
186
|
+
return lines.join("\n");
|
|
187
|
+
}
|
|
188
|
+
|
|
113
189
|
const transferEnv = (session, remoteDirectory = ".") => ({ ...process.env, NOMAC_TRANSFER_SESSION: JSON.stringify({ ...session, remoteDirectory }) });
|
|
114
190
|
|
|
115
191
|
function conflictError(paths, side) {
|
|
@@ -120,24 +196,27 @@ function conflictError(paths, side) {
|
|
|
120
196
|
}
|
|
121
197
|
|
|
122
198
|
export async function syncProject(session, store, { overwrite = false, dryRun = false, run = runProcess } = {}) {
|
|
123
|
-
const
|
|
124
|
-
|
|
199
|
+
const selection = projectFiles(store.project);
|
|
200
|
+
const paths = [...selection.files, ...selection.links].sort();
|
|
201
|
+
if (!paths.length) throw new CliError("no project files", "Nothing to send: every file is in a skipped folder. Add `!` lines to .nomacignore for what the Mac needs.");
|
|
125
202
|
const local = Object.create(null);
|
|
126
|
-
for (const path of
|
|
203
|
+
for (const path of paths) local[path] = await fingerprint(join(store.project, path));
|
|
127
204
|
const baseline = Object.assign(Object.create(null), store.baseline());
|
|
128
|
-
const previous = await remoteFingerprints(session,
|
|
129
|
-
const unsafe =
|
|
130
|
-
if (unsafe.length) throw new CliError("unsafe remote path", `Remote paths are
|
|
131
|
-
const conflicts =
|
|
205
|
+
const previous = await remoteFingerprints(session, paths, { run });
|
|
206
|
+
const unsafe = paths.filter(path => ["symlink", "other"].includes(previous[path]));
|
|
207
|
+
if (unsafe.length) throw new CliError("unsafe remote path", `Remote paths are below a symlinked folder or have incompatible types: ${unsafe.slice(0, 10).join(", ")}. Resolve them inside the workspace first.`);
|
|
208
|
+
const conflicts = paths.filter(path => previous[path] !== "missing" && previous[path] !== local[path] && previous[path] !== baseline[path]);
|
|
132
209
|
if (conflicts.length && !overwrite) throw conflictError(conflicts, "Remote");
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
210
|
+
const changed = paths.filter(path => previous[path] !== local[path]), summary = summarize(store.project, selection);
|
|
211
|
+
if (dryRun) return { dry_run: true, files: paths.length, changed, ...summary };
|
|
212
|
+
// --links sends a symlink as a link: its target is never read or followed.
|
|
213
|
+
const output = await run("rsync", [...transferArgs(), "--links", "--from0", "--files-from=-", `${store.project}/`, `${session.target}:./`], { input: Buffer.from(paths.join("\0") + "\0"), env: transferEnv(session) });
|
|
214
|
+
const after = await remoteFingerprints(session, paths, { run });
|
|
215
|
+
const changedDuringTransfer = paths.filter(path => after[path] !== local[path]);
|
|
216
|
+
for (const path of paths) if (after[path] === local[path]) baseline[path] = local[path];
|
|
138
217
|
store.saveBaseline(baseline);
|
|
139
218
|
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:
|
|
219
|
+
return { files: paths.length, changed, ...summary, output: output.stdout };
|
|
141
220
|
}
|
|
142
221
|
|
|
143
222
|
/** Refuse symlink ancestors, including the destination itself, before local writes. */
|
|
@@ -166,7 +245,7 @@ export async function pullProject(session, store, destination, { from = destinat
|
|
|
166
245
|
if (kind === "missing") {
|
|
167
246
|
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
247
|
}
|
|
169
|
-
if (kind === "symlink") throw new CliError("unsafe remote path", `${from} is a symlink.
|
|
248
|
+
if (kind === "symlink" || kind.startsWith("link:")) throw new CliError("unsafe remote path", `${from} is a symlink. Pull the folder or file it points to instead.`);
|
|
170
249
|
const single = typeof kind === "string" && kind.startsWith("file:") ? posix.basename(from) : null;
|
|
171
250
|
const source = single ? posix.dirname(from) : from;
|
|
172
251
|
// cp semantics for a single file: into the directory if one exists, else to that name.
|
package/src/sessions.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { parseArgs } from "node:util";
|
|
|
5
5
|
import { CliError } from "./api.mjs";
|
|
6
6
|
import { sessionStore } from "./session-store.mjs";
|
|
7
7
|
import { forwardingArg, prepareWorkspace, runProcess, shellQuote, sshArgs, validateTarget, workspaceCommand } from "./session-ssh.mjs";
|
|
8
|
-
import { pullProject, syncProject } from "./session-transfer.mjs";
|
|
8
|
+
import { describeSync, pullProject, syncProject } from "./session-transfer.mjs";
|
|
9
9
|
|
|
10
10
|
export const SESSION_HELP = `Nomac 2.0 — SSH workspace preview
|
|
11
11
|
|
|
@@ -132,7 +132,7 @@ export async function sessionCommand(command, argv, { store: suppliedStore, run
|
|
|
132
132
|
if (command === "sync") {
|
|
133
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
134
|
const result = await syncProject(session, store, { overwrite: values.overwrite, dryRun: values["dry-run"], run });
|
|
135
|
-
return output(result,
|
|
135
|
+
return output(result, describeSync(result));
|
|
136
136
|
}
|
|
137
137
|
if (command === "pull") {
|
|
138
138
|
if (positionals.length !== 1) throw new CliError("pull usage", "Usage: nomac pull LOCAL_DIRECTORY [--from REMOTE_DIRECTORY] [--dry-run] [--overwrite]");
|
package/src/sign.mjs
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
import { api as defaultApi, CliError } from "./api.mjs";
|
|
3
|
+
|
|
4
|
+
export const SIGN_HELP = `Usage: nomac sign [path] [--scheme NAME] [--container PATH] [--team ISSUER_ID] [--session ses_…] [--json]
|
|
5
|
+
Set up App Store signing for the project on this folder's Mac (path: the project folder
|
|
6
|
+
on the Mac, relative to the synced folder; default .). NoMac reads the project there,
|
|
7
|
+
makes the App Store profiles its app and extensions need with your App Store Connect key,
|
|
8
|
+
which stays on NoMac's server, and installs them with your certificate. The project
|
|
9
|
+
itself is not changed. Then build with your own tools and the files it prints.`;
|
|
10
|
+
|
|
11
|
+
export async function signCommand(args, { api = defaultApi, print = console.log, store } = {}) {
|
|
12
|
+
const { values, positionals } = parseArgs({ args, allowPositionals: true, strict: true, options: {
|
|
13
|
+
scheme: { type: "string" }, container: { type: "string" }, team: { type: "string" }, session: { type: "string" }, json: { type: "boolean" }, help: { type: "boolean", short: "h" },
|
|
14
|
+
} });
|
|
15
|
+
if (values.help) return print(SIGN_HELP);
|
|
16
|
+
if (positionals.length > 1) throw new CliError("sign usage", SIGN_HELP);
|
|
17
|
+
const session = values.session ?? store?.load()?.id;
|
|
18
|
+
if (!session || !/^ses_[A-Za-z0-9_-]{1,80}$/.test(session)) throw new CliError("no Mac", "No Mac for this folder. Start one with nomac start and sync the project (nomac sync .), or pass --session ses_….");
|
|
19
|
+
const body = { ...(positionals[0] ? { path: positionals[0] } : {}), ...(values.scheme ? { scheme: values.scheme } : {}), ...(values.container ? { container: values.container } : {}), ...(values.team ? { issuer_id: values.team } : {}) };
|
|
20
|
+
const result = await api("POST", `/api/v1/mac/sessions/${encodeURIComponent(session)}/signing`, { body });
|
|
21
|
+
if (values.json) { print(JSON.stringify(result, null, 2)); return result; }
|
|
22
|
+
print([
|
|
23
|
+
`Signing set up for ${result.project.scheme} (${result.project.container}), Apple team ${result.team_id}, with ${result.identity.name}.`,
|
|
24
|
+
...result.changes.map((change) => ` ${change}`),
|
|
25
|
+
...result.profiles.map((profile) => ` ${profile.bundle_id}: ${profile.name}, until ${profile.expires_at.slice(0, 10)}`),
|
|
26
|
+
"",
|
|
27
|
+
"Build on the Mac with your own tools:",
|
|
28
|
+
...result.commands.xcodebuild.map((line) => ` ${line}`),
|
|
29
|
+
` Flutter: ${result.commands.flutter}`,
|
|
30
|
+
` fastlane: ${result.commands.fastlane}`,
|
|
31
|
+
...(result.build_number ? ["", `Build number ${result.build_number} is reserved for this build; the commands set it.`] : []),
|
|
32
|
+
...(result.app_record ? ["", `Before TestFlight: ${result.app_record.message}`, ` ${result.app_record.url}`] : []),
|
|
33
|
+
"",
|
|
34
|
+
"Then send the .ipa to TestFlight with nomac testflight <path to the .ipa>.",
|
|
35
|
+
].join("\n"));
|
|
36
|
+
return result;
|
|
37
|
+
}
|