@nomac/cli 0.8.0 → 0.9.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 +2 -1
- package/package.json +1 -1
- package/skill/SKILL.md +33 -2
- package/src/api.mjs +4 -2
- package/src/cli.mjs +30 -0
- package/src/hosted-sessions.mjs +3 -1
- package/src/human-steps.mjs +13 -0
- package/src/mac-tools.mjs +7 -1
- package/src/mcp.mjs +3 -2
- package/src/notarize.mjs +71 -0
- package/src/publish.mjs +76 -0
- package/src/secrets.mjs +136 -0
- package/src/session-transfer.mjs +117 -38
- package/src/sessions.mjs +2 -2
- package/src/sign.mjs +40 -0
- package/src/testflight.mjs +85 -0
package/bin/nomac.mjs
CHANGED
|
@@ -5,6 +5,7 @@ main(process.argv.slice(2)).catch((err) => {
|
|
|
5
5
|
const args = process.argv.slice(2);
|
|
6
6
|
if (["start", "sync", "pull", "stop", "session", "transport", "exec", "job", "output"].includes(args[0]) && args.includes("--json")) {
|
|
7
7
|
console.error(JSON.stringify({ error: { code: err?.code ?? "session_error", message: err?.friendly ?? err?.message ?? String(err), ...(err?.details ? { details: err.details } : {}) } }));
|
|
8
|
-
|
|
8
|
+
// An empty friendly message means the command already printed why it failed.
|
|
9
|
+
} else if (err?.friendly !== "") console.error(err?.friendly ?? err?.message ?? String(err));
|
|
9
10
|
process.exit(err?.exitCode ?? 1);
|
|
10
11
|
});
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -44,8 +44,9 @@ with the same request key.
|
|
|
44
44
|
Keep the same request key and arguments after uncertain responses; inspect
|
|
45
45
|
the original job before retrying. Output retrieval failure is not job failure.
|
|
46
46
|
Managed jobs have a 600-second limit; SSH can run longer within session limits.
|
|
47
|
-
CLI sync
|
|
48
|
-
|
|
47
|
+
CLI sync sends every file (.env and keys included) and symlinks as links, and
|
|
48
|
+
skips dependency and build folders, .git and folders .gitignore lists; a `!`
|
|
49
|
+
line in .nomacignore sends any of them. It preserves remote extra files. See the screen with `nomac screenshot` (saves
|
|
49
50
|
`nomac-screenshot.jpg`; `--simulator` for the iOS Simulator) or MCP
|
|
50
51
|
`screenshot_mac`, which returns an image you can look at. Record a video for
|
|
51
52
|
the human with `nomac record`, do the work, then `nomac record stop` (saves the
|
|
@@ -58,6 +59,36 @@ Pipe xcodebuild through `xcbeautify`. Persistent disks are not currently offered
|
|
|
58
59
|
Report failures using `report_issue` with `session_id`, optional `job_id` and
|
|
59
60
|
a description without secrets. Read https://nomac.app/llms.txt for full limits.
|
|
60
61
|
|
|
62
|
+
## Secrets, signing and shipping
|
|
63
|
+
|
|
64
|
+
Never ask for a secret in the chat. `nomac secrets request NAME --description
|
|
65
|
+
TEXT` (MCP `request_secret`) gives the human a link to enter it; they can also
|
|
66
|
+
run `nomac secrets set NAME` themselves. Every Mac receives the account's
|
|
67
|
+
secrets as variables and files, masked in command output; `nomac secrets
|
|
68
|
+
install` refreshes a running Mac.
|
|
69
|
+
|
|
70
|
+
The human connects Apple once at https://nomac.app/apple; the App Store
|
|
71
|
+
Connect key stays on NoMac's server. Then, on the Mac:
|
|
72
|
+
|
|
73
|
+
1. `nomac sign [path]` (MCP `set_up_signing`) makes and installs the App Store
|
|
74
|
+
profiles the project needs and prints the files, the reserved build number
|
|
75
|
+
and the xcodebuild, Flutter and fastlane commands. The project is unchanged;
|
|
76
|
+
build with your own tools.
|
|
77
|
+
2. `nomac testflight path/to/App.ipa` (MCP `send_to_testflight`) puts it on
|
|
78
|
+
TestFlight. Relay the human's answer to Apple's encryption question (asked
|
|
79
|
+
once per app), never guess.
|
|
80
|
+
3. `nomac publish App.ipa --whats-new TEXT` stages the App Store version and
|
|
81
|
+
lists Apple's blockers; submit with the printed `--confirm` command only
|
|
82
|
+
after the human approves. MCP: `publish` with the send's project_id and
|
|
83
|
+
build_id.
|
|
84
|
+
4. Mac apps: sign with the Developer ID certificate, `--options runtime
|
|
85
|
+
--timestamp`, then `nomac notarize App.app` (MCP `notarize`) notarizes and
|
|
86
|
+
staples it.
|
|
87
|
+
|
|
88
|
+
A step only a person can take (creating the app in App Store Connect,
|
|
89
|
+
agreements, an App Group) comes back as needs_human with its link: give it to
|
|
90
|
+
the human, then call again.
|
|
91
|
+
|
|
61
92
|
## Builder (existing subscribers only)
|
|
62
93
|
|
|
63
94
|
The separate iOS Builder publishing product is closed to new subscriptions.
|
package/src/api.mjs
CHANGED
|
@@ -65,13 +65,15 @@ export async function api(method, path, { body, raw, idempotencyKey } = {}) {
|
|
|
65
65
|
throw new CliError(`API returned non-JSON (HTTP ${res.status})`, "The response could not be read. Reconcile the operation before retrying; reuse its request_key.", res.status, "bad_response", { code: "bad_response", ...(idempotencyKey ? { request_key: idempotencyKey } : {}) });
|
|
66
66
|
}
|
|
67
67
|
if (!res.ok) {
|
|
68
|
-
|
|
68
|
+
// Some answers carry more beside the error, such as publish's blockers and findings.
|
|
69
|
+
const { error, ...beside } = json && typeof json === "object" && !Array.isArray(json) ? json : {};
|
|
70
|
+
const err = error && typeof error === "object" ? error : {};
|
|
69
71
|
throw new CliError(
|
|
70
72
|
`HTTP ${res.status} ${err.code ?? ""}`,
|
|
71
73
|
err.message ?? `API error (HTTP ${res.status})`,
|
|
72
74
|
res.status,
|
|
73
75
|
err.code,
|
|
74
|
-
{ ...err, ...(idempotencyKey ? { request_key: idempotencyKey } : {}) },
|
|
76
|
+
{ ...beside, ...err, ...(idempotencyKey ? { request_key: idempotencyKey } : {}) },
|
|
75
77
|
);
|
|
76
78
|
}
|
|
77
79
|
return json;
|
package/src/cli.mjs
CHANGED
|
@@ -23,6 +23,11 @@ Use it:
|
|
|
23
23
|
nomac screenshot save the Mac's screen as nomac-screenshot.jpg
|
|
24
24
|
nomac record record a video; nomac record stop saves it with a share link
|
|
25
25
|
nomac status state, cost so far and when it stops
|
|
26
|
+
nomac secrets variables and files every Mac receives (nomac secrets --help)
|
|
27
|
+
nomac sign [path] set up App Store signing for the project on the Mac
|
|
28
|
+
nomac testflight app.ipa send a signed .ipa on the Mac to TestFlight
|
|
29
|
+
nomac publish app.ipa submit it for App Review (--confirm sends it)
|
|
30
|
+
nomac notarize App.app notarize a Developer ID-signed Mac app and staple it
|
|
26
31
|
|
|
27
32
|
Agents:
|
|
28
33
|
nomac mcp MCP server for Claude Code, Codex, Cursor and others
|
|
@@ -100,6 +105,31 @@ export async function main(argv) {
|
|
|
100
105
|
// A Mac you own reports through session status.
|
|
101
106
|
return cmd === "status" ? sessionCommand("session", ["status", ...rest]) : sessionCommand(cmd, rest);
|
|
102
107
|
}
|
|
108
|
+
case "sign": {
|
|
109
|
+
const { signCommand } = await import("./sign.mjs");
|
|
110
|
+
const { sessionStore } = await import("./session-store.mjs");
|
|
111
|
+
return signCommand(rest, { store: sessionStore() });
|
|
112
|
+
}
|
|
113
|
+
case "testflight": {
|
|
114
|
+
const { testflightCommand } = await import("./testflight.mjs");
|
|
115
|
+
const { sessionStore } = await import("./session-store.mjs");
|
|
116
|
+
return testflightCommand(rest, { store: sessionStore() });
|
|
117
|
+
}
|
|
118
|
+
case "publish": {
|
|
119
|
+
const { publishCommand } = await import("./publish.mjs");
|
|
120
|
+
const { sessionStore } = await import("./session-store.mjs");
|
|
121
|
+
return publishCommand(rest, { store: sessionStore() });
|
|
122
|
+
}
|
|
123
|
+
case "notarize": {
|
|
124
|
+
const { notarizeCommand } = await import("./notarize.mjs");
|
|
125
|
+
const { sessionStore } = await import("./session-store.mjs");
|
|
126
|
+
return notarizeCommand(rest, { store: sessionStore() });
|
|
127
|
+
}
|
|
128
|
+
case "secrets": {
|
|
129
|
+
const { secretsCommand } = await import("./secrets.mjs");
|
|
130
|
+
const { sessionStore } = await import("./session-store.mjs");
|
|
131
|
+
return secretsCommand(rest, { store: sessionStore() });
|
|
132
|
+
}
|
|
103
133
|
case "login": {
|
|
104
134
|
const key = rest[0];
|
|
105
135
|
if (key) {
|
package/src/hosted-sessions.mjs
CHANGED
|
@@ -186,6 +186,8 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
186
186
|
const wait = ["start", "stop"].includes(command) ? integer(values.wait, command === "start" ? 300 : 60, 0, command === "start" ? 600 : 300) : 0;
|
|
187
187
|
if (command === "start" && values["request-key"] !== undefined && !validRequestKey(values["request-key"])) throw new CliError("Use a request key of 8–128 letters, digits, underscores, periods, colons or hyphens.");
|
|
188
188
|
const output = result => {
|
|
189
|
+
// Sync, pull and the other commands answer with their own result, which has no session.
|
|
190
|
+
if (values.json || !["start", "stop"].includes(command)) { print(JSON.stringify(result, null, 2)); return result; }
|
|
189
191
|
const billing = result.session?.billing;
|
|
190
192
|
const usage = billing?.mode === "weekly_included"
|
|
191
193
|
? `Included time used: ${billing.used_seconds} seconds. No extra charge.`
|
|
@@ -193,7 +195,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
193
195
|
const next = result.session.state === "ready" ? "Next: nomac sync . → nomac ssh"
|
|
194
196
|
: result.session.failure_code === "capacity_unavailable" ? "No Mac was free within 5 minutes; nothing was charged. Run nomac start again in a few minutes."
|
|
195
197
|
: result.session.state === "queued" ? "Waiting for a free Mac (waiting is free). Check again: nomac start" : "Check: nomac session status --json";
|
|
196
|
-
print(
|
|
198
|
+
print(`${result.session.id}: ${result.session.state}\n${next}\n${usage}`);
|
|
197
199
|
return result;
|
|
198
200
|
};
|
|
199
201
|
const saveView = (local, view) => {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Steps only a person can take, from an answer's needs_human, as numbered lines with the link last. */
|
|
2
|
+
export function humanSteps(details) {
|
|
3
|
+
const human = details?.needs_human;
|
|
4
|
+
if (!human) return [];
|
|
5
|
+
return [...(human.steps ?? []).map((step, index) => ` ${index + 1}. ${step}`), ...(human.url ? [` ${human.url}`] : [])];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** An API error that needs a person: its message followed by the steps. */
|
|
9
|
+
export function withHumanSteps(error) {
|
|
10
|
+
const steps = humanSteps(error?.details);
|
|
11
|
+
if (steps.length) error.friendly = [error.friendly, ...steps].join("\n");
|
|
12
|
+
return error;
|
|
13
|
+
}
|
package/src/mac-tools.mjs
CHANGED
|
@@ -22,7 +22,13 @@ export const MAC_TOOLS = [
|
|
|
22
22
|
{ name: "get_mac_credits", description: "Read the prepaid Mac credit balance: spendable dollars, spendable_seconds (how long a Mac can run on it), amounts held by a running session, pricing (including bonus_tiers for bigger purchases), recent purchases and first_purchase (true until the first paid top-up). Credit never expires. If spendable_seconds is below what the task needs, call get_mac_checkout.", inputSchema: schema({}), handler: async () => text(await api("GET", "/api/v1/mac/credits")) },
|
|
23
23
|
{ name: "start_mac", description: "Allocate a full cloud Mac VM, billed from prepaid credit at $0.80 an hour by the minute. If it returns insufficient_credit, call get_mac_checkout, have the human pay, then retry with the same request_key. Supply a stable request_key; SSH public_key is optional for HTTPS-only agents; reuse both after a lost response. Inspect the returned offer and limits. Poll get_mac_session until ready. No project pipeline is imposed; stop_mac deletes the VM and files.", inputSchema: schema({ public_key: { type: "string" }, request_key: key, limits: { type: "object", properties: { max_duration_seconds: { type: "integer", minimum: 60, maximum: 28800 }, idle_timeout_seconds: { type: "integer", minimum: 60, maximum: 3600 }, spend_cap_microunits: { type: "string", pattern: "^[1-9][0-9]{0,17}$" } }, additionalProperties: false } }, ["request_key"]), handler: async ({ request_key, ...body }) => text(await api("POST", "/api/v1/mac/sessions", { body, idempotencyKey: request_key })) },
|
|
24
24
|
{ name: "list_mac_sessions", description: "Find existing Mac sessions, including after losing a response. Shows state, cost, limits and cleanup.", inputSchema: schema({}), handler: async () => text(await api("GET", "/api/v1/mac/sessions")) },
|
|
25
|
-
{ name: "get_mac_session", description: "Read live Mac allocation state, cost, funded time and cleanup confirmation. queued/provisioning means wait; stopping is not yet confirmed cleanup.", inputSchema: schema({ session_id: session }, ["session_id"]), handler: async args => text(await api("GET", base(args))) },
|
|
25
|
+
{ name: "get_mac_session", description: "Read live Mac allocation state, cost, funded time and cleanup confirmation, and once ready the credentials installed on it (secrets, signing identities and profiles, by name and path, never values). queued/provisioning means wait; stopping is not yet confirmed cleanup.", inputSchema: schema({ session_id: session }, ["session_id"]), handler: async args => text(await api("GET", base(args))) },
|
|
26
|
+
{ name: "list_secrets", description: "List the account's secrets by name: variables (set in every shell and command on the Mac) and files (with their path on the Mac), whether every Mac receives them or only when asked, sizes, and secrets an agent asked for that the person has not entered yet. Values are never returned.", inputSchema: schema({}), handler: async () => text(await api("GET", "/api/v1/secrets")) },
|
|
27
|
+
{ name: "request_secret", description: "Get a link where the person enters a secret in their browser, so its value never passes through this chat: an API token, a .env value, a .p12 certificate or a config file. kind variable is one line of text, set as an environment variable on the Mac; kind file is stored at destination (under the home folder, like ~/.netrc; default ~/.nomac/secrets/<name>). Give the person the url. Then list_secrets shows it, new Macs receive it, and install_secrets puts it on a running Mac. With a shell on this machine, the person can also run nomac secrets set NAME themselves. Never ask for a secret in the chat.", inputSchema: schema({ name: { type: "string", minLength: 1, maxLength: 128 }, kind: { type: "string", enum: ["variable", "file"] }, description: { type: "string", minLength: 1, maxLength: 500, description: "What the person should enter and why; shown to them" }, destination: { type: "string", maxLength: 256 }, delivery: { type: "string", enum: ["every_mac", "on_request"] } }, ["name", "kind", "description"]), handler: async body => text(await api("POST", "/api/v1/secrets/requests", { body })) },
|
|
28
|
+
{ name: "set_up_signing", description: "Set up App Store signing for a project on the Mac, with the App Store Connect key kept on NoMac's server: reads the project at path (its scheme, app and every extension, with entitlements), registers missing bundle IDs, switches on the capabilities Apple's API can, makes the App Store profiles, and installs them with the account's certificate in a keychain on the Mac. The project never changes. Returns a build settings file (.xcconfig) and export options with the exact xcodebuild, Flutter and fastlane commands. Creates a distribution certificate at Apple only when the account has none. Expo and Capacitor apps need their ios folder first. A step only a person can take comes back as needs_human with its link.", inputSchema: schema({ session_id: session, path: { type: "string", minLength: 1, maxLength: 1024, description: "The project folder on the Mac, relative to the workspace" }, scheme: { type: "string", minLength: 1, maxLength: 256 }, container: { type: "string", minLength: 1, maxLength: 1024 }, issuer_id: { type: "string", maxLength: 64 } }, ["session_id"]), handler: async ({ session_id, ...body }) => text(await api("POST", `${base({ session_id })}/signing`, { body })) },
|
|
29
|
+
{ name: "send_to_testflight", description: "Send a signed .ipa on the Mac to TestFlight, with the App Store Connect key kept on NoMac's server: NoMac creates the upload at Apple and the Mac sends only the file. Apple then processes the build, usually for 5 to 15 minutes with no Mac needed, and NoMac sets the app's export-compliance answer and What to Test and puts it on TestFlight for the internal testers. Repeat the call with the returned request_key to follow it: sending, processing, on_testflight or failed. Once on_testflight, publish with the returned project_id and build_id submits it for App Review. Apple asks once per app whether it uses encryption beyond HTTPS and Apple's own: pass the person's answer as uses_non_exempt_encryption, never a guess. A build number Apple already has comes back with the next free one. A step only a person can take, such as creating the app in App Store Connect, comes back as needs_human with its link.", inputSchema: schema({ session_id: session, path: { type: "string", minLength: 1, maxLength: 1024, description: "The signed .ipa on the Mac, relative to the workspace or absolute" }, notes: { type: "string", minLength: 1, maxLength: 4000, description: "What to Test, shown to testers" }, uses_non_exempt_encryption: { type: "boolean", description: "The person's answer, asked once per app: does it use encryption beyond HTTPS and Apple's own?" }, issuer_id: { type: "string", maxLength: 64 }, request_key: { ...key, description: "Repeat with the request_key from the first answer to follow that send" }, wait_seconds: { type: "integer", minimum: 0, maximum: 50, description: "Wait up to this long for progress (50 while following)" } }, ["session_id", "path"]), handler: async ({ session_id, ...body }) => text(await api("POST", `${base({ session_id })}/testflight`, { body })) },
|
|
30
|
+
{ name: "notarize", description: "Notarize a Developer ID-signed Mac app, disk image or installer on the Mac (.app, .dmg, .pkg or .zip), with the App Store Connect key kept on NoMac's server: the Mac zips an .app and sends the file to Apple with temporary credentials for this one submission. Apple checks it, usually for a few minutes, then the Mac staples the ticket so it opens without a warning, even offline. Repeat the call with the returned request_key to follow it: preparing, uploading, in_progress, stapling, then accepted, invalid or rejected with Apple's issues. Sign first with the Developer ID Application certificate (the person uploads it at nomac.app/apple), the hardened runtime and a secure timestamp: codesign --force --options runtime --timestamp --sign \"Developer ID Application: …\". Keep the Mac running until it is stapled.", inputSchema: schema({ session_id: session, path: { type: "string", minLength: 1, maxLength: 1024, description: "The .app, .dmg, .pkg or .zip on the Mac, relative to the workspace or absolute" }, issuer_id: { type: "string", maxLength: 64 }, request_key: { ...key, description: "Repeat with the request_key from the first answer to follow that notarization" }, wait_seconds: { type: "integer", minimum: 0, maximum: 50, description: "Wait up to this long for progress (50 while following)" } }, ["session_id", "path"]), handler: async ({ session_id, ...body }) => text(await api("POST", `${base({ session_id })}/notarize`, { body })) },
|
|
31
|
+
{ name: "install_secrets", description: "Install the account's secrets and signing identities on a running Mac now: every-Mac secrets, the on-request secrets named in names, and what the Mac already has; a deleted secret is removed. Macs receive every-Mac secrets on their own when they become ready, so call this after the person adds or changes one.", inputSchema: schema({ session_id: session, names: { type: "array", items: { type: "string", minLength: 1, maxLength: 128 }, maxItems: 100 } }, ["session_id"]), handler: async ({ session_id, names = [] }) => text(await api("POST", `${base({ session_id })}/secrets`, { body: { names } })) },
|
|
26
32
|
{ name: "open_mac_desktop", description: "Open the Mac's full macOS desktop in the human's browser on this machine, with mouse and keyboard and nothing to install. Use it when the human wants to see or click something on the Mac, for example Xcode, the Simulator or a sign-in window. The session must be ready. If opened_in_browser is false, give the human the returned url; it opens after they sign in to nomac.app. The session stays busy while the desktop is open and the page disconnects after 15 minutes without input. To show the iOS Simulator there, boot a device with simctl, then run open \"$(xcode-select -p)/Applications/Simulator.app\" with exec_mac. macOS has no RDP.", inputSchema: schema({ session_id: session, open: { type: "boolean", default: true, description: "Open the link in this machine's browser." } }, ["session_id"]), handler: async ({ session_id, open = true }) => {
|
|
27
33
|
const view = await api("GET", base({ session_id }));
|
|
28
34
|
if (view?.session?.state !== "ready") return text({ error: { code: "session_not_ready", message: "The Mac is not ready. Wait until get_mac_session shows ready, then call open_mac_desktop again." } });
|
package/src/mcp.mjs
CHANGED
|
@@ -23,6 +23,7 @@ const INSTRUCTIONS = [
|
|
|
23
23
|
"exec_mac returns a quick command's output in the same call; pass wait_seconds: 50 for builds and tests. A command still running comes back as a job: poll get_mac_job with its job_id, then read_mac_output.",
|
|
24
24
|
"Never rerun a command to get its output; after a lost response, repeat the call with the same request_key and arguments.",
|
|
25
25
|
"Write files with write_mac_file, not heredocs in exec_mac: a command is at most 64 KiB.",
|
|
26
|
+
"Shipping without the Apple key on the Mac: set_up_signing on the project returns the files and commands that sign a build with the agent's own tools; send_to_testflight sends the signed .ipa to TestFlight; publish submits it for App Review; notarize notarizes and staples a Developer ID-signed Mac app. Never ask for a secret in the chat: request_secret gives the human a link, and every Mac receives the account's secrets.",
|
|
26
27
|
"Always stop_mac when finished, after reading any output you need, so credit is not spent on an idle Mac.",
|
|
27
28
|
].join(" ");
|
|
28
29
|
|
|
@@ -298,12 +299,12 @@ export const TOOLS = [
|
|
|
298
299
|
{
|
|
299
300
|
name: "publish",
|
|
300
301
|
description:
|
|
301
|
-
"Submit for App Store review (3-step reviewSubmissions). Requires confirm:true with authorization for this app/version; run once without confirm first to see the staged result + any Apple blockers. Costs nothing; the review decision takes ~1-3 days. On pending, repeat unchanged arguments with the returned request_key; get_store_operation recovers a lost response.",
|
|
302
|
+
"Submit for App Store review (3-step reviewSubmissions). Takes the app's newest build on TestFlight, or build_id: a Builder build or one sent with send_to_testflight, whose answer gives project_id and build_id. Requires confirm:true with authorization for this app/version; run once without confirm first to see the staged result + any Apple blockers. Costs nothing; the review decision takes ~1-3 days. On pending, repeat unchanged arguments with the returned request_key; get_store_operation recovers a lost response.",
|
|
302
303
|
inputSchema: {
|
|
303
304
|
type: "object",
|
|
304
305
|
properties: {
|
|
305
306
|
project_id: { type: "string" },
|
|
306
|
-
build_id: { type: "string", description: "Exact
|
|
307
|
+
build_id: { type: "string", description: "Exact NoMac build (bld_…) to attach, from send_to_testflight or build" },
|
|
307
308
|
asc_submission_id: { type: "string", description: "Exact Apple submission from get_review" },
|
|
308
309
|
resolve_rejection: { type: "boolean", default: false, description: "Assert this version rejection was fixed; mark its item ready for review" },
|
|
309
310
|
confirm: { type: "boolean", default: false },
|
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-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,40 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
import { api as defaultApi, CliError } from "./api.mjs";
|
|
3
|
+
import { withHumanSteps } from "./human-steps.mjs";
|
|
4
|
+
|
|
5
|
+
export const SIGN_HELP = `Usage: nomac sign [path] [--scheme NAME] [--container PATH] [--team ISSUER_ID] [--session ses_…] [--json]
|
|
6
|
+
Set up App Store signing for the project on this folder's Mac (path: the project folder
|
|
7
|
+
on the Mac, relative to the synced folder; default .). NoMac reads the project there,
|
|
8
|
+
makes the App Store profiles its app and extensions need with your App Store Connect key,
|
|
9
|
+
which stays on NoMac's server, and installs them with your certificate. The project
|
|
10
|
+
itself is not changed. Then build with your own tools and the files it prints.`;
|
|
11
|
+
|
|
12
|
+
export async function signCommand(args, { api = defaultApi, print = console.log, store } = {}) {
|
|
13
|
+
const { values, positionals } = parseArgs({ args, allowPositionals: true, strict: true, options: {
|
|
14
|
+
scheme: { type: "string" }, container: { type: "string" }, team: { type: "string" }, session: { type: "string" }, json: { type: "boolean" }, help: { type: "boolean", short: "h" },
|
|
15
|
+
} });
|
|
16
|
+
if (values.help) return print(SIGN_HELP);
|
|
17
|
+
if (positionals.length > 1) throw new CliError("sign usage", SIGN_HELP);
|
|
18
|
+
const session = values.session ?? store?.load()?.id;
|
|
19
|
+
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_….");
|
|
20
|
+
const body = { ...(positionals[0] ? { path: positionals[0] } : {}), ...(values.scheme ? { scheme: values.scheme } : {}), ...(values.container ? { container: values.container } : {}), ...(values.team ? { issuer_id: values.team } : {}) };
|
|
21
|
+
let result;
|
|
22
|
+
try { result = await api("POST", `/api/v1/mac/sessions/${encodeURIComponent(session)}/signing`, { body }); }
|
|
23
|
+
catch (error) { throw values.json ? error : withHumanSteps(error); }
|
|
24
|
+
if (values.json) { print(JSON.stringify(result, null, 2)); return result; }
|
|
25
|
+
print([
|
|
26
|
+
`Signing set up for ${result.project.scheme} (${result.project.container}), Apple team ${result.team_id}, with ${result.identity.name}.`,
|
|
27
|
+
...result.changes.map((change) => ` ${change}`),
|
|
28
|
+
...result.profiles.map((profile) => ` ${profile.bundle_id}: ${profile.name}, until ${profile.expires_at.slice(0, 10)}`),
|
|
29
|
+
"",
|
|
30
|
+
"Build on the Mac with your own tools:",
|
|
31
|
+
...result.commands.xcodebuild.map((line) => ` ${line}`),
|
|
32
|
+
` Flutter: ${result.commands.flutter}`,
|
|
33
|
+
` fastlane: ${result.commands.fastlane}`,
|
|
34
|
+
...(result.build_number ? ["", `Build number ${result.build_number} is reserved for this build; the commands set it.`] : []),
|
|
35
|
+
...(result.app_record ? ["", `Before TestFlight: ${result.app_record.message}`, ` ${result.app_record.url}`] : []),
|
|
36
|
+
"",
|
|
37
|
+
"Then send the .ipa to TestFlight with nomac testflight <path to the .ipa>.",
|
|
38
|
+
].join("\n"));
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { api as defaultApi, CliError } from "./api.mjs";
|
|
4
|
+
import { humanSteps, withHumanSteps } from "./human-steps.mjs";
|
|
5
|
+
|
|
6
|
+
export const TESTFLIGHT_HELP = `Usage: nomac testflight <ipa> [--notes TEXT] [--encryption no|yes] [--team ISSUER_ID] [--session ses_…] [--no-wait] [--json]
|
|
7
|
+
nomac testflight --id tfu_…
|
|
8
|
+
Send a signed .ipa on this folder's Mac to TestFlight (ipa: its path on the Mac, relative to
|
|
9
|
+
the synced folder). Your App Store Connect key stays on NoMac's server; the Mac only sends
|
|
10
|
+
the file to Apple. Apple then processes the build, usually for 5 to 15 minutes, with no Mac
|
|
11
|
+
needed, and NoMac puts it on TestFlight for your internal testers.
|
|
12
|
+
--notes TEXT What to Test, shown to testers
|
|
13
|
+
--encryption no|yes Apple's export-compliance question, answered once per app. Most apps
|
|
14
|
+
answer no: HTTPS and Apple's own encryption are exempt.
|
|
15
|
+
--no-wait start sending and return; follow later with --id`;
|
|
16
|
+
|
|
17
|
+
const DONE = new Set(["on_testflight", "failed"]);
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
function describe(view) {
|
|
21
|
+
const app = `${view.app.bundle_id} ${view.app.version} (${view.app.build_number})`;
|
|
22
|
+
switch (view.state) {
|
|
23
|
+
case "sending": return `Sending ${app} from the Mac to Apple…`;
|
|
24
|
+
case "processing": return `Apple has ${app} and is processing it (usually 5 to 15 minutes; no Mac needed).${view.waiting_for ? `\n Waiting for: ${view.waiting_for}` : ""}\n Stopping this wait does not stop it: follow it later with nomac testflight --id ${view.id}`;
|
|
25
|
+
case "on_testflight": return `On TestFlight: ${app}. Internal testers can install it from the TestFlight app.\n ${view.url}\n ${view.app.platform === "IOS"
|
|
26
|
+
? `To submit it for App Review: nomac publish --project ${view.project_id} --build ${view.build_id}` : "Submit it for App Review in App Store Connect."}`;
|
|
27
|
+
// With a build_id, Apple had the file and refused it while processing; without one, it never reached Apple.
|
|
28
|
+
case "failed": return [`${view.build_id ? "Apple did not accept the build" : "Not sent"}: ${view.error?.message ?? "the send failed."}`, ...humanSteps(view.error)].join("\n");
|
|
29
|
+
default: return `${app}: ${view.state}`;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function testflightCommand(args, { api = defaultApi, print = console.log, store } = {}) {
|
|
34
|
+
const { values, positionals } = parseArgs({ args, allowPositionals: true, strict: true, options: {
|
|
35
|
+
notes: { type: "string" }, encryption: { type: "string" }, team: { type: "string" }, session: { type: "string" },
|
|
36
|
+
id: { type: "string" }, "no-wait": { type: "boolean" }, json: { type: "boolean" }, help: { type: "boolean", short: "h" },
|
|
37
|
+
} });
|
|
38
|
+
if (values.help) return print(TESTFLIGHT_HELP);
|
|
39
|
+
if (values.encryption !== undefined && !["yes", "no"].includes(values.encryption)) throw new CliError("encryption usage", "--encryption takes no or yes.");
|
|
40
|
+
let view;
|
|
41
|
+
if (values.id) {
|
|
42
|
+
if (positionals.length) throw new CliError("testflight usage", TESTFLIGHT_HELP);
|
|
43
|
+
view = await api("GET", `/api/v1/testflight/${encodeURIComponent(values.id)}`);
|
|
44
|
+
} else {
|
|
45
|
+
if (positionals.length !== 1) throw new CliError("testflight usage", TESTFLIGHT_HELP);
|
|
46
|
+
const session = values.session ?? store?.load()?.id;
|
|
47
|
+
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 the .ipa there, or pass --session ses_….");
|
|
48
|
+
const body = {
|
|
49
|
+
path: positionals[0], request_key: `cli-testflight-${randomUUID()}`, wait_seconds: values["no-wait"] ? 0 : 50,
|
|
50
|
+
...(values.notes ? { notes: values.notes } : {}),
|
|
51
|
+
...(values.encryption ? { uses_non_exempt_encryption: values.encryption === "yes" } : {}),
|
|
52
|
+
...(values.team ? { issuer_id: values.team } : {}),
|
|
53
|
+
};
|
|
54
|
+
try { view = await api("POST", `/api/v1/mac/sessions/${encodeURIComponent(session)}/testflight`, { body }); }
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (!(error instanceof CliError) || values.json) throw error;
|
|
57
|
+
if (error.code === "encryption_answer_required") {
|
|
58
|
+
error.friendly = `${error.details.question}\nAsk the person who owns the app, then run this again with --encryption no or --encryption yes. NoMac remembers the answer for this app. Most apps answer no.`;
|
|
59
|
+
} else if (error.code === "needs_human") {
|
|
60
|
+
withHumanSteps(error);
|
|
61
|
+
}
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
let last = "";
|
|
66
|
+
const show = (current) => {
|
|
67
|
+
if (values.json) return;
|
|
68
|
+
const line = describe(current);
|
|
69
|
+
if (line !== last) print(line);
|
|
70
|
+
last = line;
|
|
71
|
+
};
|
|
72
|
+
show(view);
|
|
73
|
+
if (!values["no-wait"]) {
|
|
74
|
+
while (!DONE.has(view.state)) {
|
|
75
|
+
view = await api("GET", `/api/v1/testflight/${encodeURIComponent(view.id)}?wait_seconds=50`);
|
|
76
|
+
show(view);
|
|
77
|
+
}
|
|
78
|
+
} else if (!DONE.has(view.state) && !values.json) {
|
|
79
|
+
print(`Follow it with: nomac testflight --id ${view.id}`);
|
|
80
|
+
}
|
|
81
|
+
if (values.json) print(JSON.stringify(view, null, 2));
|
|
82
|
+
// The reason is already printed; exit non-zero without saying it twice.
|
|
83
|
+
if (view.state === "failed") throw Object.assign(new CliError("testflight failed", ""), { friendly: "" });
|
|
84
|
+
return view;
|
|
85
|
+
}
|