@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.
@@ -0,0 +1,89 @@
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 TESTFLIGHT_HELP = `Usage: nomac testflight <ipa> [--notes TEXT] [--encryption no|yes] [--team ISSUER_ID] [--session ses_…] [--no-wait] [--json]
6
+ nomac testflight --id tfu_…
7
+ Send a signed .ipa on this folder's Mac to TestFlight (ipa: its path on the Mac, relative to
8
+ the synced folder). Your App Store Connect key stays on NoMac's server; the Mac only sends
9
+ the file to Apple. Apple then processes the build, usually for 5 to 15 minutes, with no Mac
10
+ needed, and NoMac puts it on TestFlight for your internal testers.
11
+ --notes TEXT What to Test, shown to testers
12
+ --encryption no|yes Apple's export-compliance question, answered once per app. Most apps
13
+ answer no: HTTPS and Apple's own encryption are exempt.
14
+ --no-wait start sending and return; follow later with --id`;
15
+
16
+ const DONE = new Set(["on_testflight", "failed"]);
17
+
18
+ /** Steps a person must take, from an error's needs_human. */
19
+ function humanSteps(details) {
20
+ const human = details?.needs_human;
21
+ if (!human) return [];
22
+ return [...(human.steps ?? []).map((step, index) => ` ${index + 1}. ${step}`), ...(human.url ? [` ${human.url}`] : [])];
23
+ }
24
+
25
+ function describe(view) {
26
+ const app = `${view.app.bundle_id} ${view.app.version} (${view.app.build_number})`;
27
+ switch (view.state) {
28
+ case "sending": return `Sending ${app} from the Mac to Apple…`;
29
+ 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}`;
30
+ case "on_testflight": return `On TestFlight: ${app}. Internal testers can install it from the TestFlight app.\n ${view.url}\n ${view.app.platform === "IOS"
31
+ ? `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."}`;
32
+ case "failed": return [`Not sent: ${view.error?.message ?? "the send failed."}`, ...humanSteps(view.error)].join("\n");
33
+ default: return `${app}: ${view.state}`;
34
+ }
35
+ }
36
+
37
+ export async function testflightCommand(args, { api = defaultApi, print = console.log, store } = {}) {
38
+ const { values, positionals } = parseArgs({ args, allowPositionals: true, strict: true, options: {
39
+ notes: { type: "string" }, encryption: { type: "string" }, team: { type: "string" }, session: { type: "string" },
40
+ id: { type: "string" }, "no-wait": { type: "boolean" }, json: { type: "boolean" }, help: { type: "boolean", short: "h" },
41
+ } });
42
+ if (values.help) return print(TESTFLIGHT_HELP);
43
+ if (values.encryption !== undefined && !["yes", "no"].includes(values.encryption)) throw new CliError("encryption usage", "--encryption takes no or yes.");
44
+ let view;
45
+ if (values.id) {
46
+ if (positionals.length) throw new CliError("testflight usage", TESTFLIGHT_HELP);
47
+ view = await api("GET", `/api/v1/testflight/${encodeURIComponent(values.id)}`);
48
+ } else {
49
+ if (positionals.length !== 1) throw new CliError("testflight usage", TESTFLIGHT_HELP);
50
+ const session = values.session ?? store?.load()?.id;
51
+ 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_….");
52
+ const body = {
53
+ path: positionals[0], request_key: `cli-testflight-${randomUUID()}`, wait_seconds: values["no-wait"] ? 0 : 50,
54
+ ...(values.notes ? { notes: values.notes } : {}),
55
+ ...(values.encryption ? { uses_non_exempt_encryption: values.encryption === "yes" } : {}),
56
+ ...(values.team ? { issuer_id: values.team } : {}),
57
+ };
58
+ try { view = await api("POST", `/api/v1/mac/sessions/${encodeURIComponent(session)}/testflight`, { body }); }
59
+ catch (error) {
60
+ if (!(error instanceof CliError) || values.json) throw error;
61
+ if (error.code === "encryption_answer_required") {
62
+ 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.`;
63
+ } else if (error.code === "needs_human") {
64
+ error.friendly = [error.friendly, ...humanSteps(error.details)].join("\n");
65
+ }
66
+ throw error;
67
+ }
68
+ }
69
+ let last = "";
70
+ const show = (current) => {
71
+ if (values.json) return;
72
+ const line = describe(current);
73
+ if (line !== last) print(line);
74
+ last = line;
75
+ };
76
+ show(view);
77
+ if (!values["no-wait"]) {
78
+ while (!DONE.has(view.state)) {
79
+ view = await api("GET", `/api/v1/testflight/${encodeURIComponent(view.id)}?wait_seconds=50`);
80
+ show(view);
81
+ }
82
+ } else if (!DONE.has(view.state) && !values.json) {
83
+ print(`Follow it with: nomac testflight --id ${view.id}`);
84
+ }
85
+ if (values.json) print(JSON.stringify(view, null, 2));
86
+ // The reason is already printed; exit non-zero without saying it twice.
87
+ if (view.state === "failed") throw Object.assign(new CliError("testflight failed", ""), { friendly: "" });
88
+ return view;
89
+ }