green-line-cli 1.0.2

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/dist/index.js ADDED
@@ -0,0 +1,2821 @@
1
+ #!/usr/bin/env node
2
+ import "dotenv/config";
3
+ import { Buffer } from "node:buffer";
4
+ import { spawn } from "node:child_process";
5
+ import { randomBytes, randomUUID } from "node:crypto";
6
+ import { copyFile, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
7
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
8
+ import { homedir, tmpdir } from "node:os";
9
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
10
+ import { createInterface } from "node:readline/promises";
11
+ import { Command } from "commander";
12
+ const CLI_CONFIG_PATH = join(homedir(), ".config", "green-line", "config.json");
13
+ const CLI_STATE_PATH = join(homedir(), ".config", "green-line", "state.json");
14
+ const CLI_STORAGE_DIR = join(homedir(), ".config", "green-line", "storage");
15
+ const INITIAL_STATE = {
16
+ users: [],
17
+ sessions: [],
18
+ consents: [],
19
+ builds: [],
20
+ submissions: [],
21
+ appleConnections: [],
22
+ appleSigningAssets: []
23
+ };
24
+ function loadLocalState() {
25
+ if (!existsSync(CLI_STATE_PATH)) {
26
+ return structuredClone(INITIAL_STATE);
27
+ }
28
+ try {
29
+ const raw = readFileSync(CLI_STATE_PATH, "utf8");
30
+ return JSON.parse(raw);
31
+ }
32
+ catch {
33
+ return structuredClone(INITIAL_STATE);
34
+ }
35
+ }
36
+ function saveLocalState(state) {
37
+ mkdirSync(dirname(CLI_STATE_PATH), { recursive: true });
38
+ writeFileSync(CLI_STATE_PATH, JSON.stringify(state, null, 2), "utf8");
39
+ }
40
+ const localState = loadLocalState();
41
+ function nowIso() {
42
+ return new Date().toISOString();
43
+ }
44
+ function loadCliConfig() {
45
+ if (!existsSync(CLI_CONFIG_PATH)) {
46
+ return {};
47
+ }
48
+ try {
49
+ const raw = readFileSync(CLI_CONFIG_PATH, "utf8");
50
+ return JSON.parse(raw);
51
+ }
52
+ catch {
53
+ return {};
54
+ }
55
+ }
56
+ function saveCliConfig(config) {
57
+ mkdirSync(dirname(CLI_CONFIG_PATH), { recursive: true });
58
+ writeFileSync(CLI_CONFIG_PATH, JSON.stringify(config, null, 2), "utf8");
59
+ }
60
+ let cliConfig = loadCliConfig();
61
+ function getApiBaseUrl(value) {
62
+ return value ?? process.env.API_BASE_URL ?? cliConfig.apiBaseUrl ?? "http://localhost:4000";
63
+ }
64
+ function getAuthToken() {
65
+ return process.env.GL_AUTH_TOKEN ?? cliConfig.authToken;
66
+ }
67
+ function requiresAuth(url) {
68
+ const pathname = new URL(url).pathname;
69
+ return pathname !== "/v1/auth/login";
70
+ }
71
+ function ensureSignedIn() {
72
+ if (!getAuthToken()) {
73
+ throw new Error("Please sign in first: `gl auth login --email <you@example.com>`.");
74
+ }
75
+ }
76
+ function withQuery(url, query) {
77
+ const parsed = new URL(url);
78
+ for (const [key, value] of Object.entries(query)) {
79
+ if (value !== undefined) {
80
+ parsed.searchParams.set(key, String(value));
81
+ }
82
+ }
83
+ return parsed.toString();
84
+ }
85
+ function authUserFromToken(token) {
86
+ if (!token) {
87
+ return null;
88
+ }
89
+ const session = localState.sessions.find((record) => record.token === token);
90
+ if (!session) {
91
+ return null;
92
+ }
93
+ session.lastUsedAt = nowIso();
94
+ saveLocalState(localState);
95
+ return localState.users.find((user) => user.id === session.userId) ?? null;
96
+ }
97
+ async function processBuild(buildId) {
98
+ const build = localState.builds.find((record) => record.id === buildId);
99
+ if (!build) {
100
+ return;
101
+ }
102
+ const logsBlobName = `${buildId}/build.log`;
103
+ const logsFilePath = join(CLI_STORAGE_DIR, "logs", logsBlobName);
104
+ await mkdir(dirname(logsFilePath), { recursive: true });
105
+ build.status = "running";
106
+ build.logsBlobName = logsBlobName;
107
+ build.errorMessage = null;
108
+ build.updatedAt = nowIso();
109
+ saveLocalState(localState);
110
+ const logLines = [
111
+ `[${nowIso()}] Build started`,
112
+ `[${nowIso()}] Working directory=${process.cwd()}`
113
+ ];
114
+ const appendLog = async (line) => {
115
+ logLines.push(`[${nowIso()}] ${line}`);
116
+ await writeFile(logsFilePath, `${logLines.join("\n")}\n`, "utf8");
117
+ };
118
+ try {
119
+ await appendLog("Checking fastlane installation");
120
+ await ensureFastlaneInstalled();
121
+ await appendLog("Checking xcodebuild availability");
122
+ await runCommand("xcodebuild", ["-version"]);
123
+ const iosDir = existsSync(join(process.cwd(), "ios")) ? join(process.cwd(), "ios") : process.cwd();
124
+ await appendLog(`Resolving iOS project in ${iosDir}`);
125
+ const entries = await readdir(iosDir, { withFileTypes: true });
126
+ const workspaces = entries
127
+ .filter((entry) => (entry.isDirectory() || entry.isFile()) && entry.name.endsWith(".xcworkspace") && !entry.name.toLowerCase().includes("pods"))
128
+ .map((entry) => join(iosDir, entry.name));
129
+ const projects = entries
130
+ .filter((entry) => (entry.isDirectory() || entry.isFile()) && entry.name.endsWith(".xcodeproj") && !entry.name.toLowerCase().includes("pods"))
131
+ .map((entry) => join(iosDir, entry.name));
132
+ const workspacePath = workspaces[0] ?? null;
133
+ const projectPath = workspacePath ? null : (projects[0] ?? null);
134
+ if (!workspacePath && !projectPath) {
135
+ throw new Error("No .xcworkspace or .xcodeproj found. Run this from your iOS app root.");
136
+ }
137
+ await appendLog(`Using ${workspacePath ? `workspace=${workspacePath}` : `project=${projectPath}`}`);
138
+ const listArgs = workspacePath
139
+ ? ["-list", "-json", "-workspace", workspacePath]
140
+ : ["-list", "-json", "-project", projectPath];
141
+ const listResult = await runCommand("xcodebuild", listArgs);
142
+ const parsed = JSON.parse(listResult.stdout);
143
+ const schemes = parsed.workspace?.schemes ?? parsed.project?.schemes ?? [];
144
+ const scheme = pickBestScheme(schemes, workspacePath, projectPath);
145
+ if (!scheme) {
146
+ throw new Error("No Xcode scheme found for build.");
147
+ }
148
+ await appendLog(`Using scheme=${scheme}`);
149
+ let signingProfileUuid = null;
150
+ let signingProfileName = null;
151
+ let signingTeamId = null;
152
+ let signingIdentityHash = null;
153
+ const signingAsset = localState.appleSigningAssets
154
+ .filter((item) => item.orgSlug === build.orgSlug && item.projectSlug === build.projectSlug)
155
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0];
156
+ const expectedBundleId = signingAsset?.bundleId ?? `com.${bundleSegment(build.orgSlug)}.${bundleSegment(build.projectSlug)}`;
157
+ const newestLocalProfile = await findNewestLocalProvisioningProfile(expectedBundleId);
158
+ const profileBase64 = newestLocalProfile?.base64 ?? signingAsset?.provisioningProfileBase64;
159
+ if (newestLocalProfile) {
160
+ await appendLog(`Using newest local provisioning profile: ${newestLocalProfile.path}`);
161
+ }
162
+ if (profileBase64) {
163
+ const signingWorkspace = await mkdtemp(join(tmpdir(), "gl-signing-"));
164
+ try {
165
+ const profilePath = join(signingWorkspace, "profile.mobileprovision");
166
+ await writeFile(profilePath, Buffer.from(profileBase64, "base64"));
167
+ const profileDump = await runCommand("security", ["cms", "-D", "-i", profilePath]);
168
+ signingProfileUuid = /<key>UUID<\/key>\s*<string>([^<]+)<\/string>/m.exec(profileDump.stdout)?.[1] ?? null;
169
+ signingProfileName = /<key>Name<\/key>\s*<string>([^<]+)<\/string>/m.exec(profileDump.stdout)?.[1] ?? null;
170
+ signingTeamId = /<key>TeamIdentifier<\/key>\s*<array>\s*<string>([^<]+)<\/string>/m.exec(profileDump.stdout)?.[1] ?? null;
171
+ const identitiesOutput = await runCommand("security", ["find-identity", "-v", "-p", "codesigning"]);
172
+ const availableIdentityHashes = new Set(Array.from(identitiesOutput.stdout.matchAll(/\b([A-F0-9]{40})\b/g)).map((match) => match[1]));
173
+ const developerCertArrayMatch = /<key>DeveloperCertificates<\/key>\s*<array>([\s\S]*?)<\/array>/m.exec(profileDump.stdout);
174
+ if (developerCertArrayMatch?.[1]) {
175
+ const certDataMatches = Array.from(developerCertArrayMatch[1].matchAll(/<data>([\s\S]*?)<\/data>/g));
176
+ for (const certDataMatch of certDataMatches) {
177
+ const certData = certDataMatch[1].replace(/\s+/g, "");
178
+ if (!certData) {
179
+ continue;
180
+ }
181
+ const certDerPath = join(signingWorkspace, `${randomUUID()}.cer`);
182
+ await writeFile(certDerPath, Buffer.from(certData, "base64"));
183
+ try {
184
+ const certFingerprint = await runCommand("openssl", ["x509", "-inform", "DER", "-in", certDerPath, "-noout", "-fingerprint", "-sha1"]);
185
+ const fingerprintMatch = /Fingerprint=([A-F0-9:]+)/i.exec(certFingerprint.stdout);
186
+ const hash = fingerprintMatch?.[1]?.replace(/:/g, "").toUpperCase() ?? null;
187
+ if (hash && availableIdentityHashes.has(hash)) {
188
+ signingIdentityHash = hash;
189
+ break;
190
+ }
191
+ }
192
+ catch {
193
+ continue;
194
+ }
195
+ }
196
+ }
197
+ const profileInstallDir = join(homedir(), "Library", "MobileDevice", "Provisioning Profiles");
198
+ await mkdir(profileInstallDir, { recursive: true });
199
+ const profileInstallPath = join(profileInstallDir, `${signingProfileUuid ?? randomUUID()}.mobileprovision`);
200
+ await copyFile(profilePath, profileInstallPath);
201
+ await appendLog(`Installed provisioning profile${signingProfileName ? ` (${signingProfileName})` : ""}`);
202
+ if (signingIdentityHash) {
203
+ await appendLog(`Matched signing identity from profile: ${signingIdentityHash}`);
204
+ }
205
+ if (signingAsset.distributionCertP12Base64) {
206
+ const p12Path = join(signingWorkspace, "distribution.p12");
207
+ await writeFile(p12Path, Buffer.from(signingAsset.distributionCertP12Base64, "base64"));
208
+ const importArgs = [
209
+ "import",
210
+ p12Path,
211
+ "-k",
212
+ join(homedir(), "Library", "Keychains", "login.keychain-db"),
213
+ "-P",
214
+ signingAsset.distributionCertPassword ?? "",
215
+ "-T",
216
+ "/usr/bin/codesign",
217
+ "-T",
218
+ "/usr/bin/security"
219
+ ];
220
+ try {
221
+ await runCommand("security", importArgs);
222
+ await appendLog("Imported distribution certificate into login keychain");
223
+ }
224
+ catch (error) {
225
+ if (error instanceof CommandExecutionError) {
226
+ const output = `${error.stdout}\n${error.stderr}`;
227
+ if (!/already exists/i.test(output)) {
228
+ throw error;
229
+ }
230
+ await appendLog("Distribution certificate already exists in keychain");
231
+ }
232
+ else {
233
+ throw error;
234
+ }
235
+ }
236
+ }
237
+ }
238
+ finally {
239
+ await rm(signingWorkspace, { recursive: true, force: true });
240
+ }
241
+ }
242
+ const team = inferAppleTeamId();
243
+ const hasManualSigningInputs = Boolean(signingProfileUuid || signingProfileName);
244
+ const resolvedTeamId = hasManualSigningInputs
245
+ ? (signingTeamId ?? team.value)
246
+ : (team.value ?? signingTeamId);
247
+ if (resolvedTeamId) {
248
+ await appendLog(`Using Apple Team ID=${resolvedTeamId}${(hasManualSigningInputs && signingTeamId) ? " (provisioning profile)" : (team.value ? ` (${team.source})` : "")}`);
249
+ }
250
+ else {
251
+ await appendLog("No Apple Team ID configured; build may fail if project has no Development Team set.");
252
+ }
253
+ const artifactDir = join(CLI_STORAGE_DIR, "artifacts", buildId);
254
+ await mkdir(artifactDir, { recursive: true });
255
+ const artifactName = `${build.projectSlug || "app"}-${build.id.slice(0, 8)}.ipa`;
256
+ const gymArgs = [
257
+ "run",
258
+ "gym",
259
+ `scheme:${scheme}`,
260
+ `output_directory:${artifactDir}`,
261
+ `output_name:${artifactName}`,
262
+ "clean:true",
263
+ "skip_package_ipa:false",
264
+ "export_method:app-store"
265
+ ];
266
+ if (workspacePath) {
267
+ gymArgs.push(`workspace:${workspacePath}`);
268
+ }
269
+ else if (projectPath) {
270
+ gymArgs.push(`project:${projectPath}`);
271
+ }
272
+ if (hasManualSigningInputs && signingProfileName) {
273
+ const exportOptionsPath = join(artifactDir, "ExportOptions.plist");
274
+ const exportOptionsPlist = [
275
+ "<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
276
+ "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">",
277
+ "<plist version=\"1.0\">",
278
+ "<dict>",
279
+ " <key>method</key>",
280
+ " <string>app-store-connect</string>",
281
+ " <key>signingStyle</key>",
282
+ " <string>manual</string>",
283
+ " <key>provisioningProfiles</key>",
284
+ " <dict>",
285
+ ` <key>${expectedBundleId}</key>`,
286
+ ` <string>${signingProfileName}</string>`,
287
+ " </dict>",
288
+ "</dict>",
289
+ "</plist>"
290
+ ].join("\n");
291
+ await writeFile(exportOptionsPath, exportOptionsPlist, "utf8");
292
+ gymArgs.push(`export_options:${exportOptionsPath}`);
293
+ await appendLog(`Using export options plist: ${exportOptionsPath}`);
294
+ }
295
+ const xcargsParts = [
296
+ hasManualSigningInputs ? "CODE_SIGN_STYLE=Manual" : "CODE_SIGN_STYLE=Automatic"
297
+ ];
298
+ if (!hasManualSigningInputs) {
299
+ xcargsParts.unshift("-allowProvisioningUpdates");
300
+ }
301
+ if (resolvedTeamId) {
302
+ xcargsParts.push(`DEVELOPMENT_TEAM=${resolvedTeamId}`);
303
+ }
304
+ if (signingProfileUuid) {
305
+ xcargsParts.push(`PROVISIONING_PROFILE=${signingProfileUuid}`);
306
+ }
307
+ // Prefer UUID-only profile pinning; profile name can resolve to stale duplicates.
308
+ if (hasManualSigningInputs && signingIdentityHash) {
309
+ xcargsParts.push(`CODE_SIGN_IDENTITY=${signingIdentityHash}`);
310
+ }
311
+ gymArgs.push(`xcargs:${xcargsParts.join(" ")}`);
312
+ await appendLog("Running fastlane gym (this may take several minutes)");
313
+ const gymResult = await runCommand("fastlane", gymArgs);
314
+ if (gymResult.stdout.trim()) {
315
+ await appendLog(gymResult.stdout.trim());
316
+ }
317
+ if (gymResult.stderr.trim()) {
318
+ await appendLog(gymResult.stderr.trim());
319
+ }
320
+ const ipaPath = join(artifactDir, artifactName);
321
+ if (!existsSync(ipaPath)) {
322
+ const discovered = await findFirstFileWithExtensions(artifactDir, [".ipa"]);
323
+ if (!discovered) {
324
+ throw new Error(`Build completed but no .ipa found in ${artifactDir}`);
325
+ }
326
+ build.artifactBlobName = `${buildId}/${basename(discovered)}`;
327
+ build.artifactUrl = `file://${discovered}`;
328
+ }
329
+ else {
330
+ build.artifactBlobName = `${buildId}/${artifactName}`;
331
+ build.artifactUrl = `file://${ipaPath}`;
332
+ }
333
+ build.status = "succeeded";
334
+ build.errorMessage = null;
335
+ build.updatedAt = nowIso();
336
+ saveLocalState(localState);
337
+ await appendLog("Build completed successfully");
338
+ }
339
+ catch (error) {
340
+ const message = error instanceof Error ? error.message : String(error);
341
+ let resolvedMessage = message;
342
+ if (error instanceof CommandExecutionError) {
343
+ const combined = `${error.stdout}\n${error.stderr}`;
344
+ if (/requires a provisioning profile with the Push Notifications feature/i.test(combined)) {
345
+ resolvedMessage = [
346
+ message,
347
+ "Build archive succeeded, but IPA export failed because the provisioning profile is missing Push Notifications capability.",
348
+ "Enable Push Notifications for the App ID in Apple Developer, regenerate the App Store provisioning profile, download it, then rerun `gl apple setup --from-expo --apple-id <apple-id>` and `gl build ios start`."
349
+ ].join("\n");
350
+ }
351
+ }
352
+ build.status = "failed";
353
+ build.artifactBlobName = null;
354
+ build.artifactUrl = null;
355
+ build.errorMessage = resolvedMessage;
356
+ build.updatedAt = nowIso();
357
+ saveLocalState(localState);
358
+ await appendLog(`Build failed: ${resolvedMessage}`);
359
+ if (error instanceof CommandExecutionError) {
360
+ const stderr = error.stderr.trim();
361
+ const stdout = error.stdout.trim();
362
+ if (stderr) {
363
+ await appendLog(`fastlane stderr: ${stderr}`);
364
+ }
365
+ if (stdout) {
366
+ await appendLog(`fastlane stdout: ${stdout}`);
367
+ }
368
+ }
369
+ }
370
+ }
371
+ async function processSubmission(submissionId) {
372
+ const submission = localState.submissions.find((record) => record.id === submissionId);
373
+ if (!submission) {
374
+ return;
375
+ }
376
+ const build = localState.builds.find((record) => record.id === submission.buildId);
377
+ if (!build) {
378
+ submission.status = "failed";
379
+ submission.errorMessage = `Build ${submission.buildId} not found`;
380
+ submission.updatedAt = nowIso();
381
+ saveLocalState(localState);
382
+ return;
383
+ }
384
+ if (!build.artifactUrl?.startsWith("file://")) {
385
+ submission.status = "failed";
386
+ submission.errorMessage = "Submission requires a local IPA artifact URL (file://...).";
387
+ submission.updatedAt = nowIso();
388
+ saveLocalState(localState);
389
+ return;
390
+ }
391
+ const connection = localState.appleConnections.find((item) => (item.orgSlug === submission.orgSlug && item.projectSlug === submission.projectSlug));
392
+ if (!connection || !connection.privateKeyPem.includes("PRIVATE KEY")) {
393
+ submission.status = "failed";
394
+ submission.errorMessage = "Valid App Store Connect API key is not configured for this project.";
395
+ submission.updatedAt = nowIso();
396
+ saveLocalState(localState);
397
+ return;
398
+ }
399
+ const fileUrl = new URL(build.artifactUrl);
400
+ const ipaPath = decodeURIComponent(fileUrl.pathname);
401
+ if (!existsSync(ipaPath)) {
402
+ submission.status = "failed";
403
+ submission.errorMessage = `IPA not found at ${ipaPath}`;
404
+ submission.updatedAt = nowIso();
405
+ saveLocalState(localState);
406
+ return;
407
+ }
408
+ const logsBlobName = `${submissionId}/submit.log`;
409
+ const logsFilePath = join(CLI_STORAGE_DIR, "logs", logsBlobName);
410
+ await mkdir(dirname(logsFilePath), { recursive: true });
411
+ submission.status = "running";
412
+ submission.logsBlobName = logsBlobName;
413
+ submission.errorMessage = null;
414
+ submission.updatedAt = nowIso();
415
+ saveLocalState(localState);
416
+ const logLines = [
417
+ `[${nowIso()}] Submission started`,
418
+ `[${nowIso()}] Track=${submission.track}`,
419
+ `[${nowIso()}] IPA=${ipaPath}`
420
+ ];
421
+ const appendLog = async (line) => {
422
+ logLines.push(`[${nowIso()}] ${line}`);
423
+ await writeFile(logsFilePath, `${logLines.join("\n")}\n`, "utf8");
424
+ };
425
+ const tempDir = await mkdtemp(join(tmpdir(), "gl-asc-key-"));
426
+ const apiKeyPath = join(tempDir, "asc-api-key.json");
427
+ try {
428
+ await appendLog("Checking fastlane installation");
429
+ await ensureFastlaneInstalled();
430
+ await appendLog("Preparing App Store Connect API key payload");
431
+ await writeFile(apiKeyPath, JSON.stringify({
432
+ key_id: connection.keyId,
433
+ issuer_id: connection.issuerId,
434
+ key: connection.privateKeyPem,
435
+ in_house: false
436
+ }, null, 2), "utf8");
437
+ await appendLog("Uploading build to App Store Connect via fastlane pilot");
438
+ const { stdout, stderr } = await runCommand("fastlane", [
439
+ "run",
440
+ "pilot",
441
+ "upload",
442
+ `ipa:${ipaPath}`,
443
+ `api_key_path:${apiKeyPath}`,
444
+ "skip_waiting_for_build_processing:true"
445
+ ]);
446
+ if (stdout.trim()) {
447
+ await appendLog(stdout.trim());
448
+ }
449
+ if (stderr.trim()) {
450
+ await appendLog(stderr.trim());
451
+ }
452
+ submission.status = "succeeded";
453
+ submission.externalSubmissionId = `pilot-${submissionId.slice(0, 8)}-${Date.now()}`;
454
+ submission.errorMessage = null;
455
+ submission.updatedAt = nowIso();
456
+ saveLocalState(localState);
457
+ await appendLog("Submission upload command completed successfully");
458
+ }
459
+ catch (error) {
460
+ const message = error instanceof Error ? error.message : String(error);
461
+ let resolvedMessage = message;
462
+ if (error instanceof CommandExecutionError) {
463
+ const stderr = error.stderr.trim();
464
+ const stdout = error.stdout.trim();
465
+ resolvedMessage = [
466
+ message,
467
+ stderr ? `fastlane stderr:\n${stderr}` : "",
468
+ stdout ? `fastlane stdout:\n${stdout}` : ""
469
+ ].filter(Boolean).join("\n");
470
+ }
471
+ submission.status = "failed";
472
+ submission.externalSubmissionId = null;
473
+ submission.errorMessage = resolvedMessage;
474
+ submission.updatedAt = nowIso();
475
+ saveLocalState(localState);
476
+ await appendLog(`Submission failed: ${resolvedMessage}`);
477
+ }
478
+ finally {
479
+ await rm(tempDir, { recursive: true, force: true });
480
+ }
481
+ }
482
+ function parseRequestUrl(url) {
483
+ try {
484
+ return new URL(url);
485
+ }
486
+ catch {
487
+ return new URL(url, "http://localhost");
488
+ }
489
+ }
490
+ function formatDurationMs(ms) {
491
+ if (ms < 1000) {
492
+ return `${ms}ms`;
493
+ }
494
+ return `${(ms / 1000).toFixed(1)}s`;
495
+ }
496
+ class CliUi {
497
+ stepCounter = 0;
498
+ constructor(title) {
499
+ console.log(`\n${title}`);
500
+ console.log("=".repeat(title.length));
501
+ }
502
+ info(message) {
503
+ console.log(`- ${message}`);
504
+ }
505
+ async step(label, fn) {
506
+ this.stepCounter += 1;
507
+ const stepNumber = this.stepCounter;
508
+ const start = Date.now();
509
+ console.log(`[${stepNumber}] ${label}...`);
510
+ try {
511
+ const result = await fn();
512
+ const elapsed = Date.now() - start;
513
+ console.log(` done (${formatDurationMs(elapsed)})`);
514
+ return result;
515
+ }
516
+ catch (error) {
517
+ const elapsed = Date.now() - start;
518
+ console.log(` failed (${formatDurationMs(elapsed)})`);
519
+ throw error;
520
+ }
521
+ }
522
+ complete(message) {
523
+ console.log(`\n${message}`);
524
+ }
525
+ }
526
+ async function promptForYes(message) {
527
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
528
+ try {
529
+ const answer = (await rl.question(`${message}\nType y to continue: `)).trim().toLowerCase();
530
+ return answer === "y";
531
+ }
532
+ finally {
533
+ rl.close();
534
+ }
535
+ }
536
+ async function promptForText(message) {
537
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
538
+ try {
539
+ return (await rl.question(message)).trim();
540
+ }
541
+ finally {
542
+ rl.close();
543
+ }
544
+ }
545
+ async function promptForBuildSelection(builds) {
546
+ if (builds.length === 0) {
547
+ throw new Error("No builds available for selection.");
548
+ }
549
+ console.log("Recent successful builds:");
550
+ for (const [index, build] of builds.entries()) {
551
+ console.log(`${index + 1}) ${build.id}\t${build.orgSlug}/${build.projectSlug}\t${build.profile}\t${build.commitSha}\t${build.createdAt}`);
552
+ }
553
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
554
+ try {
555
+ const answer = (await rl.question("Select build number (press Enter for most recent #1): ")).trim();
556
+ if (!answer) {
557
+ return builds[0];
558
+ }
559
+ const parsed = Number(answer);
560
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > builds.length) {
561
+ throw new Error(`Invalid selection: ${answer}`);
562
+ }
563
+ return builds[parsed - 1];
564
+ }
565
+ finally {
566
+ rl.close();
567
+ }
568
+ }
569
+ function listRecentSucceededBuilds(options) {
570
+ const limit = options?.limit ?? 25;
571
+ return localState.builds
572
+ .filter((build) => build.status === "succeeded")
573
+ .filter((build) => !options?.orgSlug || build.orgSlug === options.orgSlug)
574
+ .filter((build) => !options?.projectSlug || build.projectSlug === options.projectSlug)
575
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
576
+ .slice(0, limit);
577
+ }
578
+ function slugify(value) {
579
+ const slug = value
580
+ .toLowerCase()
581
+ .replace(/[^a-z0-9]+/g, "-")
582
+ .replace(/^-+|-+$/g, "");
583
+ return slug || "default";
584
+ }
585
+ function bundleSegment(value) {
586
+ const cleaned = value.toLowerCase().replace(/[^a-z0-9]/g, "");
587
+ if (!cleaned) {
588
+ return "app";
589
+ }
590
+ if (/^[0-9]/.test(cleaned)) {
591
+ return `app${cleaned}`;
592
+ }
593
+ return cleaned;
594
+ }
595
+ function defaultProjectSlug() {
596
+ const cwdName = process.cwd().split("/").pop();
597
+ return slugify(cwdName ?? "ios-app");
598
+ }
599
+ function readJsonFileIfExists(filePath) {
600
+ if (!existsSync(filePath)) {
601
+ return null;
602
+ }
603
+ try {
604
+ const raw = readFileSync(filePath, "utf8");
605
+ const parsed = JSON.parse(raw);
606
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
607
+ return null;
608
+ }
609
+ return parsed;
610
+ }
611
+ catch {
612
+ return null;
613
+ }
614
+ }
615
+ function stringFromRecord(record, key) {
616
+ const value = record[key];
617
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
618
+ }
619
+ function recordFromRecord(record, key) {
620
+ const value = record[key];
621
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
622
+ return undefined;
623
+ }
624
+ return value;
625
+ }
626
+ function detectExpoProjectHints(projectDir) {
627
+ const sourceFiles = [];
628
+ const hints = { sourceFiles };
629
+ const appJsonPath = join(projectDir, "app.json");
630
+ const appConfigJsonPath = join(projectDir, "app.config.json");
631
+ const packageJsonPath = join(projectDir, "package.json");
632
+ const appJson = readJsonFileIfExists(appJsonPath);
633
+ const appConfigJson = readJsonFileIfExists(appConfigJsonPath);
634
+ const packageJson = readJsonFileIfExists(packageJsonPath);
635
+ const expoNode = appJson
636
+ ? recordFromRecord(appJson, "expo")
637
+ : (appConfigJson ? recordFromRecord(appConfigJson, "expo") : undefined);
638
+ if (appJson && expoNode) {
639
+ sourceFiles.push("app.json");
640
+ }
641
+ else if (appConfigJson && expoNode) {
642
+ sourceFiles.push("app.config.json");
643
+ }
644
+ if (packageJson) {
645
+ sourceFiles.push("package.json");
646
+ }
647
+ if (expoNode) {
648
+ const iosNode = recordFromRecord(expoNode, "ios");
649
+ hints.bundleId = iosNode ? stringFromRecord(iosNode, "bundleIdentifier") : undefined;
650
+ hints.appleTeamId = iosNode ? stringFromRecord(iosNode, "appleTeamId") : undefined;
651
+ hints.appName = stringFromRecord(expoNode, "name");
652
+ const expoSlug = stringFromRecord(expoNode, "slug");
653
+ hints.projectSlug = expoSlug ? slugify(expoSlug) : undefined;
654
+ const owner = stringFromRecord(expoNode, "owner");
655
+ hints.ownerSlug = owner ? slugify(owner) : undefined;
656
+ }
657
+ if (!hints.projectSlug && packageJson) {
658
+ const packageName = stringFromRecord(packageJson, "name");
659
+ if (packageName) {
660
+ hints.projectSlug = slugify(packageName.replace(/^@[^/]+\//, ""));
661
+ }
662
+ }
663
+ return hints;
664
+ }
665
+ function detectExpoPluginPackages(projectDir) {
666
+ const appJsonPath = join(projectDir, "app.json");
667
+ const appConfigJsonPath = join(projectDir, "app.config.json");
668
+ const appJson = readJsonFileIfExists(appJsonPath);
669
+ const appConfigJson = readJsonFileIfExists(appConfigJsonPath);
670
+ const expoNode = appJson
671
+ ? recordFromRecord(appJson, "expo")
672
+ : (appConfigJson ? recordFromRecord(appConfigJson, "expo") : undefined);
673
+ if (!expoNode) {
674
+ return [];
675
+ }
676
+ const pluginsNode = expoNode.plugins;
677
+ if (!Array.isArray(pluginsNode)) {
678
+ return [];
679
+ }
680
+ const packages = new Set();
681
+ for (const pluginEntry of pluginsNode) {
682
+ if (typeof pluginEntry === "string" && pluginEntry.trim()) {
683
+ const value = pluginEntry.trim();
684
+ if (!value.startsWith(".")) {
685
+ packages.add(value);
686
+ }
687
+ continue;
688
+ }
689
+ if (Array.isArray(pluginEntry) && typeof pluginEntry[0] === "string") {
690
+ const value = pluginEntry[0].trim();
691
+ if (value && !value.startsWith(".")) {
692
+ packages.add(value);
693
+ }
694
+ }
695
+ }
696
+ return Array.from(packages).sort((a, b) => a.localeCompare(b));
697
+ }
698
+ function readPackageDependencyNames(projectDir) {
699
+ const packageJsonPath = join(projectDir, "package.json");
700
+ const packageJson = readJsonFileIfExists(packageJsonPath);
701
+ if (!packageJson) {
702
+ return new Set();
703
+ }
704
+ const sections = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"];
705
+ const deps = new Set();
706
+ for (const section of sections) {
707
+ const node = packageJson[section];
708
+ if (!node || typeof node !== "object" || Array.isArray(node)) {
709
+ continue;
710
+ }
711
+ for (const key of Object.keys(node)) {
712
+ deps.add(key);
713
+ }
714
+ }
715
+ return deps;
716
+ }
717
+ function listProjectSourceFiles(projectDir, maxDepth = 8) {
718
+ const files = [];
719
+ const excludedDirs = new Set([".git", "node_modules", "ios", "android", "dist", "build", ".expo", ".next"]);
720
+ const allowedExtensions = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
721
+ const visit = (currentDir, depth) => {
722
+ if (depth > maxDepth || !existsSync(currentDir)) {
723
+ return;
724
+ }
725
+ const entries = readdirSync(currentDir, { withFileTypes: true });
726
+ for (const entry of entries) {
727
+ const fullPath = join(currentDir, entry.name);
728
+ if (entry.isDirectory()) {
729
+ if (excludedDirs.has(entry.name)) {
730
+ continue;
731
+ }
732
+ visit(fullPath, depth + 1);
733
+ continue;
734
+ }
735
+ if (!entry.isFile()) {
736
+ continue;
737
+ }
738
+ const extension = `.${entry.name.split(".").pop() ?? ""}`;
739
+ if (allowedExtensions.has(extension)) {
740
+ files.push(fullPath);
741
+ }
742
+ }
743
+ };
744
+ visit(projectDir, 0);
745
+ return files;
746
+ }
747
+ function packageNameFromModuleSpecifier(specifier) {
748
+ const value = specifier.trim();
749
+ if (!value || value.startsWith(".") || value.startsWith("/") || value.startsWith("node:")) {
750
+ return null;
751
+ }
752
+ if (value.startsWith("@")) {
753
+ const segments = value.split("/");
754
+ return segments.length >= 2 ? `${segments[0]}/${segments[1]}` : null;
755
+ }
756
+ return value.split("/")[0] ?? null;
757
+ }
758
+ function isExpoRelatedPackage(packageName) {
759
+ return packageName.startsWith("expo")
760
+ || packageName.startsWith("@expo/")
761
+ || packageName.startsWith("@react-native-community/")
762
+ || packageName.startsWith("@react-native-async-storage/");
763
+ }
764
+ function detectPackagesFromSourceImports(projectDir) {
765
+ const sourceFiles = listProjectSourceFiles(projectDir);
766
+ const packages = new Set();
767
+ const patterns = [
768
+ /import\s+[^\n]*?\s+from\s+["']([^"']+)["']/g,
769
+ /import\s*\(\s*["']([^"']+)["']\s*\)/g,
770
+ /require\(\s*["']([^"']+)["']\s*\)/g,
771
+ /export\s+[^\n]*?\s+from\s+["']([^"']+)["']/g
772
+ ];
773
+ for (const filePath of sourceFiles) {
774
+ let content = "";
775
+ try {
776
+ content = readFileSync(filePath, "utf8");
777
+ }
778
+ catch {
779
+ continue;
780
+ }
781
+ for (const pattern of patterns) {
782
+ for (const match of content.matchAll(pattern)) {
783
+ const rawSpecifier = match[1];
784
+ if (typeof rawSpecifier !== "string") {
785
+ continue;
786
+ }
787
+ const packageName = packageNameFromModuleSpecifier(rawSpecifier);
788
+ if (!packageName || !isExpoRelatedPackage(packageName)) {
789
+ continue;
790
+ }
791
+ packages.add(packageName);
792
+ }
793
+ }
794
+ }
795
+ return Array.from(packages).sort((a, b) => a.localeCompare(b));
796
+ }
797
+ function detectAppleTeamIdFromXcodeProject(projectDir) {
798
+ const iosDir = existsSync(join(projectDir, "ios")) ? join(projectDir, "ios") : projectDir;
799
+ if (!existsSync(iosDir)) {
800
+ return null;
801
+ }
802
+ try {
803
+ const entries = readdirSync(iosDir, { withFileTypes: true });
804
+ for (const entry of entries) {
805
+ if (!entry.name.endsWith(".xcodeproj")) {
806
+ continue;
807
+ }
808
+ const pbxprojPath = join(iosDir, entry.name, "project.pbxproj");
809
+ if (!existsSync(pbxprojPath)) {
810
+ continue;
811
+ }
812
+ const pbxproj = readFileSync(pbxprojPath, "utf8");
813
+ const match = /DEVELOPMENT_TEAM\s*=\s*([A-Z0-9]{10})\s*;/m.exec(pbxproj);
814
+ if (match?.[1]) {
815
+ return match[1];
816
+ }
817
+ }
818
+ }
819
+ catch {
820
+ return null;
821
+ }
822
+ return null;
823
+ }
824
+ async function findNewestAuthKeyP8(downloadsDir) {
825
+ if (!existsSync(downloadsDir)) {
826
+ return null;
827
+ }
828
+ const entries = await readdir(downloadsDir, { withFileTypes: true });
829
+ const candidates = entries
830
+ .filter((entry) => entry.isFile() && /^AuthKey_.+\.p8$/i.test(entry.name))
831
+ .map((entry) => join(downloadsDir, entry.name));
832
+ if (candidates.length === 0) {
833
+ return null;
834
+ }
835
+ let newest = null;
836
+ for (const candidatePath of candidates) {
837
+ const fileStat = await stat(candidatePath);
838
+ if (!newest || fileStat.mtimeMs > newest.mtimeMs) {
839
+ newest = {
840
+ path: candidatePath,
841
+ mtimeMs: fileStat.mtimeMs
842
+ };
843
+ }
844
+ }
845
+ return newest;
846
+ }
847
+ async function waitForNewAuthKeyP8(downloadsDir, timeoutMs, baselineMtimeMs) {
848
+ const started = Date.now();
849
+ while (Date.now() - started < timeoutMs) {
850
+ const newest = await findNewestAuthKeyP8(downloadsDir);
851
+ if (newest && newest.mtimeMs > baselineMtimeMs) {
852
+ return newest;
853
+ }
854
+ await new Promise((resolve) => setTimeout(resolve, 1250));
855
+ }
856
+ return null;
857
+ }
858
+ function inferKeyIdFromP8Path(filePath) {
859
+ const fileName = basename(filePath);
860
+ const match = /^AuthKey_([A-Za-z0-9]+)\.p8$/i.exec(fileName);
861
+ return match?.[1] ?? null;
862
+ }
863
+ function extractUuid(value) {
864
+ const match = /\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/i.exec(value);
865
+ return match?.[0] ?? null;
866
+ }
867
+ function isUuid(value) {
868
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value.trim());
869
+ }
870
+ function inferIssuerIdFromText(value) {
871
+ const lines = value
872
+ .split(/\r?\n/g)
873
+ .map((line) => line.trim())
874
+ .filter(Boolean);
875
+ for (const [index, line] of lines.entries()) {
876
+ if (!/issuer\s*id/i.test(line)) {
877
+ continue;
878
+ }
879
+ const sameLine = extractUuid(line);
880
+ if (sameLine) {
881
+ return sameLine;
882
+ }
883
+ for (const candidate of lines.slice(index + 1, index + 6)) {
884
+ const maybeUuid = extractUuid(candidate);
885
+ if (maybeUuid) {
886
+ return maybeUuid;
887
+ }
888
+ }
889
+ }
890
+ return extractUuid(value);
891
+ }
892
+ const ASC_PEM_BEGIN = ["-----BEGIN", "PRIVATE KEY-----"].join(" ");
893
+ const ASC_PEM_END = ["-----END", "PRIVATE KEY-----"].join(" ");
894
+ function isAscPrivateKeyPem(value) {
895
+ const normalized = value.replace(/\r\n/g, "\n").trim();
896
+ return normalized.includes(ASC_PEM_BEGIN) && normalized.includes(ASC_PEM_END);
897
+ }
898
+ async function tryReadIssuerIdFromClipboard() {
899
+ try {
900
+ const result = await runCommand("pbpaste", []);
901
+ const value = result.stdout.trim();
902
+ if (!value) {
903
+ return null;
904
+ }
905
+ return inferIssuerIdFromText(value);
906
+ }
907
+ catch {
908
+ return null;
909
+ }
910
+ }
911
+ async function tryReadIssuerIdFromBrowser() {
912
+ const scripts = [
913
+ [
914
+ "tell application \"Google Chrome\"",
915
+ "\tif (count of windows) = 0 then return \"\"",
916
+ "\tset activeTab to active tab of front window",
917
+ "\tset currentUrl to URL of activeTab",
918
+ "\tif currentUrl does not contain \"appstoreconnect.apple.com\" then return \"\"",
919
+ "\tset bodyText to execute activeTab javascript \"document && document.body ? document.body.innerText : ''\"",
920
+ "\treturn bodyText",
921
+ "end tell"
922
+ ].join("\n"),
923
+ [
924
+ "tell application \"Safari\"",
925
+ "\tif (count of windows) = 0 then return \"\"",
926
+ "\tset currentTab to current tab of front window",
927
+ "\tset currentUrl to URL of currentTab",
928
+ "\tif currentUrl does not contain \"appstoreconnect.apple.com\" then return \"\"",
929
+ "\tset bodyText to do JavaScript \"document && document.body ? document.body.innerText : ''\" in currentTab",
930
+ "\treturn bodyText",
931
+ "end tell"
932
+ ].join("\n")
933
+ ];
934
+ for (const script of scripts) {
935
+ try {
936
+ const result = await runCommand("osascript", ["-e", script]);
937
+ const candidate = inferIssuerIdFromText(result.stdout);
938
+ if (candidate) {
939
+ return candidate;
940
+ }
941
+ }
942
+ catch {
943
+ continue;
944
+ }
945
+ }
946
+ return null;
947
+ }
948
+ function isManagedAscConnection(connection) {
949
+ return connection.keyId.startsWith("MANAGED-") || connection.issuerId.startsWith("managed-");
950
+ }
951
+ function titleCase(value) {
952
+ return value
953
+ .split(/[-_.\s]+/g)
954
+ .filter(Boolean)
955
+ .map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`)
956
+ .join(" ");
957
+ }
958
+ function normalizeIpaPath(inputPath) {
959
+ const trimmed = inputPath.trim();
960
+ if (!trimmed) {
961
+ throw new Error("IPA path cannot be empty.");
962
+ }
963
+ const resolvedPath = isAbsolute(trimmed) ? trimmed : resolve(process.cwd(), trimmed);
964
+ if (!existsSync(resolvedPath)) {
965
+ throw new Error(`IPA file not found: ${resolvedPath}`);
966
+ }
967
+ if (!resolvedPath.toLowerCase().endsWith(".ipa")) {
968
+ throw new Error(`Expected an .ipa file: ${resolvedPath}`);
969
+ }
970
+ return resolvedPath;
971
+ }
972
+ function localArtifactPathFromBuild(build) {
973
+ if (!build.artifactUrl?.startsWith("file://")) {
974
+ return null;
975
+ }
976
+ try {
977
+ const fileUrl = new URL(build.artifactUrl);
978
+ return decodeURIComponent(fileUrl.pathname);
979
+ }
980
+ catch {
981
+ return null;
982
+ }
983
+ }
984
+ function findFirstDirectoryNamed(baseDir, targetDirName, maxDepth = 6) {
985
+ const visit = (currentDir, depth) => {
986
+ if (depth > maxDepth || !existsSync(currentDir)) {
987
+ return null;
988
+ }
989
+ const entries = readdirSync(currentDir, { withFileTypes: true });
990
+ for (const entry of entries) {
991
+ if (!entry.isDirectory()) {
992
+ continue;
993
+ }
994
+ const fullPath = join(currentDir, entry.name);
995
+ if (entry.name === targetDirName) {
996
+ return fullPath;
997
+ }
998
+ if (entry.name === "Pods" || entry.name === ".git" || entry.name === "node_modules") {
999
+ continue;
1000
+ }
1001
+ const nested = visit(fullPath, depth + 1);
1002
+ if (nested) {
1003
+ return nested;
1004
+ }
1005
+ }
1006
+ return null;
1007
+ };
1008
+ return visit(baseDir, 0);
1009
+ }
1010
+ function findFirstFileNamed(baseDir, targetFileName, maxDepth = 6) {
1011
+ const visit = (currentDir, depth) => {
1012
+ if (depth > maxDepth || !existsSync(currentDir)) {
1013
+ return null;
1014
+ }
1015
+ const entries = readdirSync(currentDir, { withFileTypes: true });
1016
+ for (const entry of entries) {
1017
+ const fullPath = join(currentDir, entry.name);
1018
+ if (entry.isFile() && entry.name === targetFileName) {
1019
+ return fullPath;
1020
+ }
1021
+ if (!entry.isDirectory()) {
1022
+ continue;
1023
+ }
1024
+ if (entry.name === "Pods" || entry.name === ".git" || entry.name === "node_modules") {
1025
+ continue;
1026
+ }
1027
+ const nested = visit(fullPath, depth + 1);
1028
+ if (nested) {
1029
+ return nested;
1030
+ }
1031
+ }
1032
+ return null;
1033
+ };
1034
+ return visit(baseDir, 0);
1035
+ }
1036
+ function findAppStoreIconPath(workingDirectory) {
1037
+ const iosDir = existsSync(join(workingDirectory, "ios")) ? join(workingDirectory, "ios") : workingDirectory;
1038
+ if (!existsSync(iosDir)) {
1039
+ return null;
1040
+ }
1041
+ const appIconSetDir = findFirstDirectoryNamed(iosDir, "AppIcon.appiconset", 8);
1042
+ if (!appIconSetDir) {
1043
+ return null;
1044
+ }
1045
+ const contentsPath = join(appIconSetDir, "Contents.json");
1046
+ if (existsSync(contentsPath)) {
1047
+ try {
1048
+ const contents = JSON.parse(readFileSync(contentsPath, "utf8"));
1049
+ const image = contents.images?.find((item) => item.size === "1024x1024")
1050
+ ?? contents.images?.find((item) => item.idiom === "ios-marketing")
1051
+ ?? contents.images?.find((item) => item.filename?.includes("1024"));
1052
+ if (image?.filename) {
1053
+ const candidatePath = join(appIconSetDir, image.filename);
1054
+ if (existsSync(candidatePath)) {
1055
+ return candidatePath;
1056
+ }
1057
+ }
1058
+ }
1059
+ catch {
1060
+ // Fall through to filename scan.
1061
+ }
1062
+ }
1063
+ const files = readdirSync(appIconSetDir, { withFileTypes: true })
1064
+ .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".png"))
1065
+ .map((entry) => join(appIconSetDir, entry.name));
1066
+ const with1024 = files.find((filePath) => basename(filePath).includes("1024"));
1067
+ return with1024 ?? files[0] ?? null;
1068
+ }
1069
+ function persistCliConfig(patch) {
1070
+ cliConfig = {
1071
+ ...cliConfig,
1072
+ ...patch
1073
+ };
1074
+ saveCliConfig(cliConfig);
1075
+ }
1076
+ function persistProjectDefaults(orgSlug, projectSlug, buildProfile) {
1077
+ persistCliConfig({
1078
+ lastOrgSlug: orgSlug,
1079
+ lastProjectSlug: projectSlug,
1080
+ ...(buildProfile ? { lastBuildProfile: buildProfile } : {})
1081
+ });
1082
+ }
1083
+ function inferAppleTeamId(optionValue, workingDirectory = process.cwd()) {
1084
+ if (optionValue?.trim()) {
1085
+ return { value: optionValue.trim(), source: "--team-id" };
1086
+ }
1087
+ if (process.env.GL_APPLE_TEAM_ID?.trim()) {
1088
+ return { value: process.env.GL_APPLE_TEAM_ID.trim(), source: "GL_APPLE_TEAM_ID" };
1089
+ }
1090
+ if (process.env.APPLE_TEAM_ID?.trim()) {
1091
+ return { value: process.env.APPLE_TEAM_ID.trim(), source: "APPLE_TEAM_ID" };
1092
+ }
1093
+ if (cliConfig.lastAppleTeamId?.trim()) {
1094
+ return { value: cliConfig.lastAppleTeamId.trim(), source: "saved default" };
1095
+ }
1096
+ const detected = detectAppleTeamIdFromXcodeProject(workingDirectory);
1097
+ if (detected) {
1098
+ return { value: detected, source: "xcode project" };
1099
+ }
1100
+ return { value: null, source: "none" };
1101
+ }
1102
+ function pickBestScheme(schemes, workspacePath, projectPath) {
1103
+ if (schemes.length === 0) {
1104
+ return null;
1105
+ }
1106
+ const expectedName = workspacePath
1107
+ ? basename(workspacePath, ".xcworkspace")
1108
+ : (projectPath ? basename(projectPath, ".xcodeproj") : "");
1109
+ if (expectedName) {
1110
+ const exactMatch = schemes.find((item) => item === expectedName);
1111
+ if (exactMatch) {
1112
+ return exactMatch;
1113
+ }
1114
+ }
1115
+ const nonPodNonExpo = schemes.find((item) => {
1116
+ const lower = item.toLowerCase();
1117
+ return !lower.includes("pods") && !lower.startsWith("ex");
1118
+ });
1119
+ if (nonPodNonExpo) {
1120
+ return nonPodNonExpo;
1121
+ }
1122
+ return schemes.find((item) => !item.toLowerCase().includes("pods")) ?? schemes[0];
1123
+ }
1124
+ function inferOrgSlug(optionValue) {
1125
+ if (optionValue) {
1126
+ return { value: optionValue, source: "--org" };
1127
+ }
1128
+ if (process.env.GL_ORG) {
1129
+ return { value: process.env.GL_ORG, source: "GL_ORG" };
1130
+ }
1131
+ if (cliConfig.lastOrgSlug) {
1132
+ return { value: cliConfig.lastOrgSlug, source: "saved default" };
1133
+ }
1134
+ const emailLocal = cliConfig.userEmail?.split("@")[0];
1135
+ if (emailLocal) {
1136
+ return { value: slugify(emailLocal), source: "signed-in email" };
1137
+ }
1138
+ return { value: "personal", source: "fallback" };
1139
+ }
1140
+ function inferProjectSlug(optionValue) {
1141
+ if (optionValue) {
1142
+ return { value: optionValue, source: "--project" };
1143
+ }
1144
+ if (process.env.GL_PROJECT) {
1145
+ return { value: process.env.GL_PROJECT, source: "GL_PROJECT" };
1146
+ }
1147
+ if (cliConfig.lastProjectSlug) {
1148
+ return { value: cliConfig.lastProjectSlug, source: "saved default" };
1149
+ }
1150
+ return { value: defaultProjectSlug(), source: "current folder" };
1151
+ }
1152
+ function inferBuildProfile(optionValue) {
1153
+ if (optionValue) {
1154
+ return { value: optionValue, source: "--profile" };
1155
+ }
1156
+ if (process.env.GL_BUILD_PROFILE) {
1157
+ return { value: process.env.GL_BUILD_PROFILE, source: "GL_BUILD_PROFILE" };
1158
+ }
1159
+ if (cliConfig.lastBuildProfile) {
1160
+ return { value: cliConfig.lastBuildProfile, source: "saved default" };
1161
+ }
1162
+ return { value: "prod", source: "default" };
1163
+ }
1164
+ function inferBundleId(optionValue, orgSlug, projectSlug) {
1165
+ if (optionValue) {
1166
+ return { value: optionValue, source: "--bundle-id" };
1167
+ }
1168
+ if (process.env.IOS_BUNDLE_ID) {
1169
+ return { value: process.env.IOS_BUNDLE_ID, source: "IOS_BUNDLE_ID" };
1170
+ }
1171
+ if (process.env.EXPO_IOS_BUNDLE_IDENTIFIER) {
1172
+ return { value: process.env.EXPO_IOS_BUNDLE_IDENTIFIER, source: "EXPO_IOS_BUNDLE_IDENTIFIER" };
1173
+ }
1174
+ return {
1175
+ value: `com.${bundleSegment(orgSlug)}.${bundleSegment(projectSlug)}`,
1176
+ source: "derived from org/project"
1177
+ };
1178
+ }
1179
+ async function inferCommitSha(optionValue) {
1180
+ if (optionValue) {
1181
+ return { value: optionValue, source: "--commit" };
1182
+ }
1183
+ if (process.env.GL_COMMIT_SHA) {
1184
+ return { value: process.env.GL_COMMIT_SHA, source: "GL_COMMIT_SHA" };
1185
+ }
1186
+ try {
1187
+ const { stdout } = await runCommand("git", ["rev-parse", "--short", "HEAD"]);
1188
+ const commitSha = stdout.trim();
1189
+ if (commitSha.length >= 7) {
1190
+ return { value: commitSha, source: "git HEAD" };
1191
+ }
1192
+ }
1193
+ catch {
1194
+ // Fall through to non-git fallback.
1195
+ }
1196
+ return { value: "localdev", source: "fallback" };
1197
+ }
1198
+ class CommandExecutionError extends Error {
1199
+ stdout;
1200
+ stderr;
1201
+ exitCode;
1202
+ constructor(message, stdout, stderr, exitCode) {
1203
+ super(message);
1204
+ this.stdout = stdout;
1205
+ this.stderr = stderr;
1206
+ this.exitCode = exitCode;
1207
+ }
1208
+ }
1209
+ async function postJson(url, payload) {
1210
+ const token = getAuthToken();
1211
+ if (requiresAuth(url) && !token) {
1212
+ throw new Error("Not signed in. Run `gl auth login --email <you@example.com>` first.");
1213
+ }
1214
+ const parsed = parseRequestUrl(url);
1215
+ const path = parsed.pathname;
1216
+ const body = payload;
1217
+ if (path === "/v1/auth/login") {
1218
+ const email = String(body.email ?? "").trim().toLowerCase();
1219
+ if (!email.includes("@")) {
1220
+ throw new Error("Invalid email");
1221
+ }
1222
+ let user = localState.users.find((item) => item.email === email);
1223
+ if (!user) {
1224
+ user = {
1225
+ id: randomUUID(),
1226
+ email,
1227
+ accountStatus: "active",
1228
+ createdAt: nowIso(),
1229
+ updatedAt: nowIso()
1230
+ };
1231
+ localState.users.push(user);
1232
+ }
1233
+ const authToken = randomBytes(32).toString("base64url");
1234
+ localState.sessions.push({
1235
+ userId: user.id,
1236
+ token: authToken,
1237
+ createdAt: nowIso(),
1238
+ lastUsedAt: nowIso()
1239
+ });
1240
+ saveLocalState(localState);
1241
+ return { token: authToken, user };
1242
+ }
1243
+ const user = authUserFromToken(token);
1244
+ if (!user) {
1245
+ throw new Error("Not authenticated");
1246
+ }
1247
+ if (path === "/v1/consents") {
1248
+ localState.consents.push({
1249
+ id: randomUUID(),
1250
+ userId: user.id,
1251
+ orgSlug: String(body.orgSlug ?? ""),
1252
+ projectSlug: String(body.projectSlug ?? ""),
1253
+ action: String(body.action ?? ""),
1254
+ consentText: String(body.consentText ?? ""),
1255
+ response: (body.response === "declined" ? "declined" : "accepted"),
1256
+ createdAt: nowIso()
1257
+ });
1258
+ saveLocalState(localState);
1259
+ return { consent: localState.consents.at(-1) };
1260
+ }
1261
+ if (path === "/v1/builds") {
1262
+ const build = {
1263
+ id: randomUUID(),
1264
+ orgSlug: String(body.orgSlug ?? ""),
1265
+ projectSlug: String(body.projectSlug ?? ""),
1266
+ platform: "ios",
1267
+ profile: String(body.profile ?? "prod"),
1268
+ commitSha: String(body.commitSha ?? "localdev"),
1269
+ status: "queued",
1270
+ logsBlobName: null,
1271
+ artifactBlobName: null,
1272
+ artifactUrl: null,
1273
+ errorMessage: null,
1274
+ createdAt: nowIso(),
1275
+ updatedAt: nowIso()
1276
+ };
1277
+ localState.builds.push(build);
1278
+ saveLocalState(localState);
1279
+ await processBuild(build.id);
1280
+ return { build: localState.builds.find((item) => item.id === build.id) };
1281
+ }
1282
+ if (path === "/v1/apple/connections") {
1283
+ const orgSlug = String(body.orgSlug ?? "");
1284
+ const projectSlug = String(body.projectSlug ?? "");
1285
+ const existing = localState.appleConnections.find((item) => item.orgSlug === orgSlug && item.projectSlug === projectSlug);
1286
+ const connection = {
1287
+ id: existing?.id ?? randomUUID(),
1288
+ orgSlug,
1289
+ projectSlug,
1290
+ issuerId: String(body.issuerId ?? ""),
1291
+ keyId: String(body.keyId ?? ""),
1292
+ hasPrivateKey: true,
1293
+ createdAt: existing?.createdAt ?? nowIso(),
1294
+ updatedAt: nowIso(),
1295
+ privateKeyPem: String(body.privateKeyPem ?? "")
1296
+ };
1297
+ localState.appleConnections = [
1298
+ ...localState.appleConnections.filter((item) => !(item.orgSlug === orgSlug && item.projectSlug === projectSlug)),
1299
+ connection
1300
+ ];
1301
+ saveLocalState(localState);
1302
+ const { privateKeyPem: _privateKeyPem, ...publicConnection } = connection;
1303
+ return { connection: publicConnection };
1304
+ }
1305
+ if (path === "/v1/apple/signing-assets") {
1306
+ const orgSlug = String(body.orgSlug ?? "");
1307
+ const projectSlug = String(body.projectSlug ?? "");
1308
+ const bundleId = String(body.bundleId ?? "");
1309
+ const distributionCertP12Base64 = String(body.distributionCertP12Base64 ?? "");
1310
+ const distributionCertPassword = typeof body.distributionCertPassword === "string"
1311
+ ? body.distributionCertPassword
1312
+ : undefined;
1313
+ const provisioningProfileBase64 = String(body.provisioningProfileBase64 ?? "");
1314
+ const existing = localState.appleSigningAssets.find((item) => (item.orgSlug === orgSlug && item.projectSlug === projectSlug && item.bundleId === bundleId));
1315
+ const signingAssets = {
1316
+ id: existing?.id ?? randomUUID(),
1317
+ orgSlug,
1318
+ projectSlug,
1319
+ bundleId,
1320
+ hasDistributionCert: true,
1321
+ hasProvisioningProfile: true,
1322
+ distributionCertP12Base64: distributionCertP12Base64 || existing?.distributionCertP12Base64,
1323
+ distributionCertPassword: distributionCertPassword ?? existing?.distributionCertPassword,
1324
+ provisioningProfileBase64: provisioningProfileBase64 || existing?.provisioningProfileBase64,
1325
+ createdAt: existing?.createdAt ?? nowIso(),
1326
+ updatedAt: nowIso()
1327
+ };
1328
+ localState.appleSigningAssets = [
1329
+ ...localState.appleSigningAssets.filter((item) => !(item.orgSlug === orgSlug && item.projectSlug === projectSlug && item.bundleId === bundleId)),
1330
+ signingAssets
1331
+ ];
1332
+ saveLocalState(localState);
1333
+ const { distributionCertP12Base64: _p12, distributionCertPassword: _pw, provisioningProfileBase64: _profile, ...publicSigningAssets } = signingAssets;
1334
+ return { signingAssets: publicSigningAssets };
1335
+ }
1336
+ if (path === "/v1/submissions") {
1337
+ let buildId = String(body.buildId ?? "");
1338
+ let build = localState.builds.find((item) => item.id === buildId);
1339
+ const ipaPathInput = typeof body.ipaPath === "string" ? body.ipaPath : null;
1340
+ if (!build && ipaPathInput) {
1341
+ const orgSlug = String(body.orgSlug ?? "").trim();
1342
+ const projectSlug = String(body.projectSlug ?? "").trim();
1343
+ if (!orgSlug || !projectSlug) {
1344
+ throw new Error("orgSlug and projectSlug are required when submitting with --ipa");
1345
+ }
1346
+ const ipaPath = normalizeIpaPath(ipaPathInput);
1347
+ const importedBuild = {
1348
+ id: randomUUID(),
1349
+ orgSlug,
1350
+ projectSlug,
1351
+ platform: "ios",
1352
+ profile: "external",
1353
+ commitSha: "external-ipa",
1354
+ status: "succeeded",
1355
+ logsBlobName: null,
1356
+ artifactBlobName: basename(ipaPath),
1357
+ artifactUrl: `file://${ipaPath}`,
1358
+ errorMessage: null,
1359
+ createdAt: nowIso(),
1360
+ updatedAt: nowIso()
1361
+ };
1362
+ localState.builds.push(importedBuild);
1363
+ saveLocalState(localState);
1364
+ build = importedBuild;
1365
+ buildId = importedBuild.id;
1366
+ }
1367
+ if (!build) {
1368
+ throw new Error("Build not found (provide --build or --ipa)");
1369
+ }
1370
+ if (build.status !== "succeeded") {
1371
+ throw new Error("Build must be in succeeded status before submission");
1372
+ }
1373
+ const hasConnection = localState.appleConnections.some((item) => item.orgSlug === build.orgSlug && item.projectSlug === build.projectSlug);
1374
+ if (!hasConnection) {
1375
+ throw new Error("Apple connection is not configured for this project");
1376
+ }
1377
+ const submission = {
1378
+ id: randomUUID(),
1379
+ buildId,
1380
+ orgSlug: build.orgSlug,
1381
+ projectSlug: build.projectSlug,
1382
+ track: (body.track === "appstore" ? "appstore" : "testflight"),
1383
+ status: "queued",
1384
+ logsBlobName: null,
1385
+ externalSubmissionId: null,
1386
+ errorMessage: null,
1387
+ createdAt: nowIso(),
1388
+ updatedAt: nowIso()
1389
+ };
1390
+ localState.submissions.push(submission);
1391
+ saveLocalState(localState);
1392
+ await processSubmission(submission.id);
1393
+ return { submission: localState.submissions.find((item) => item.id === submission.id) };
1394
+ }
1395
+ throw new Error(`Unsupported POST route: ${path}`);
1396
+ }
1397
+ async function getJson(url) {
1398
+ const token = getAuthToken();
1399
+ if (requiresAuth(url) && !token) {
1400
+ throw new Error("Not signed in. Run `gl auth login --email <you@example.com>` first.");
1401
+ }
1402
+ const parsed = parseRequestUrl(url);
1403
+ const path = parsed.pathname;
1404
+ const params = parsed.searchParams;
1405
+ const user = authUserFromToken(token);
1406
+ if (requiresAuth(url) && !user) {
1407
+ throw new Error("Not authenticated");
1408
+ }
1409
+ if (path === "/v1/auth/me") {
1410
+ return { user };
1411
+ }
1412
+ if (path === "/v1/builds") {
1413
+ const orgSlug = params.get("orgSlug") ?? "";
1414
+ const projectSlug = params.get("projectSlug") ?? "";
1415
+ const limit = Number(params.get("limit") ?? "20");
1416
+ const builds = localState.builds
1417
+ .filter((item) => item.orgSlug === orgSlug && item.projectSlug === projectSlug)
1418
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
1419
+ .slice(0, limit);
1420
+ return { builds };
1421
+ }
1422
+ if (path.startsWith("/v1/builds/") && path.endsWith("/logs")) {
1423
+ const buildId = path.replace("/v1/builds/", "").replace("/logs", "");
1424
+ const build = localState.builds.find((item) => item.id === buildId);
1425
+ if (!build) {
1426
+ throw new Error("Build not found");
1427
+ }
1428
+ if (!build.logsBlobName) {
1429
+ throw new Error("Logs not available yet");
1430
+ }
1431
+ const logs = await readFile(join(CLI_STORAGE_DIR, "logs", build.logsBlobName), "utf8");
1432
+ return { buildId: build.id, logs };
1433
+ }
1434
+ if (path.startsWith("/v1/builds/")) {
1435
+ const buildId = path.replace("/v1/builds/", "");
1436
+ const build = localState.builds.find((item) => item.id === buildId);
1437
+ if (!build) {
1438
+ throw new Error("Build not found");
1439
+ }
1440
+ return { build };
1441
+ }
1442
+ if (path === "/v1/apple/connections") {
1443
+ const orgSlug = params.get("orgSlug") ?? "";
1444
+ const projectSlug = params.get("projectSlug") ?? "";
1445
+ const connection = localState.appleConnections.find((item) => item.orgSlug === orgSlug && item.projectSlug === projectSlug);
1446
+ if (!connection) {
1447
+ throw new Error("Apple connection not found");
1448
+ }
1449
+ const { privateKeyPem: _privateKeyPem, ...publicConnection } = connection;
1450
+ return { connection: publicConnection };
1451
+ }
1452
+ if (path === "/v1/apple/signing-assets") {
1453
+ const orgSlug = params.get("orgSlug") ?? "";
1454
+ const projectSlug = params.get("projectSlug") ?? "";
1455
+ const bundleId = params.get("bundleId") ?? "";
1456
+ const signingAssets = localState.appleSigningAssets.find((item) => (item.orgSlug === orgSlug && item.projectSlug === projectSlug && item.bundleId === bundleId));
1457
+ if (!signingAssets) {
1458
+ throw new Error("Apple signing assets not found");
1459
+ }
1460
+ const { distributionCertP12Base64: _p12, distributionCertPassword: _pw, provisioningProfileBase64: _profile, ...publicSigningAssets } = signingAssets;
1461
+ return { signingAssets: publicSigningAssets };
1462
+ }
1463
+ if (path === "/v1/submissions") {
1464
+ const orgSlug = params.get("orgSlug") ?? "";
1465
+ const projectSlug = params.get("projectSlug") ?? "";
1466
+ const limit = Number(params.get("limit") ?? "20");
1467
+ const submissions = localState.submissions
1468
+ .filter((item) => item.orgSlug === orgSlug && item.projectSlug === projectSlug)
1469
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
1470
+ .slice(0, limit);
1471
+ return { submissions };
1472
+ }
1473
+ if (path.startsWith("/v1/submissions/") && path.endsWith("/logs")) {
1474
+ const submissionId = path.replace("/v1/submissions/", "").replace("/logs", "");
1475
+ const submission = localState.submissions.find((item) => item.id === submissionId);
1476
+ if (!submission) {
1477
+ throw new Error("Submission not found");
1478
+ }
1479
+ if (!submission.logsBlobName) {
1480
+ throw new Error("Logs not available yet");
1481
+ }
1482
+ const logs = await readFile(join(CLI_STORAGE_DIR, "logs", submission.logsBlobName), "utf8");
1483
+ return { submissionId: submission.id, logs };
1484
+ }
1485
+ if (path.startsWith("/v1/submissions/")) {
1486
+ const submissionId = path.replace("/v1/submissions/", "");
1487
+ const submission = localState.submissions.find((item) => item.id === submissionId);
1488
+ if (!submission) {
1489
+ throw new Error("Submission not found");
1490
+ }
1491
+ return { submission };
1492
+ }
1493
+ throw new Error(`Unsupported GET route: ${path}`);
1494
+ }
1495
+ async function getBinary(url) {
1496
+ const token = getAuthToken();
1497
+ if (requiresAuth(url) && !token) {
1498
+ throw new Error("Not signed in. Run `gl auth login --email <you@example.com>` first.");
1499
+ }
1500
+ const parsed = parseRequestUrl(url);
1501
+ const path = parsed.pathname;
1502
+ const user = authUserFromToken(token);
1503
+ if (!user) {
1504
+ throw new Error("Not authenticated");
1505
+ }
1506
+ if (path.startsWith("/v1/builds/") && path.endsWith("/artifact")) {
1507
+ const buildId = path.replace("/v1/builds/", "").replace("/artifact", "");
1508
+ const build = localState.builds.find((item) => item.id === buildId);
1509
+ if (!build) {
1510
+ throw new Error("Build not found");
1511
+ }
1512
+ if (!build.artifactBlobName) {
1513
+ throw new Error("Artifact not available yet");
1514
+ }
1515
+ const artifactPath = join(CLI_STORAGE_DIR, "artifacts", build.artifactBlobName);
1516
+ const content = await readFile(artifactPath);
1517
+ return {
1518
+ content,
1519
+ contentType: "application/octet-stream",
1520
+ contentDisposition: `attachment; filename=\"${basename(build.artifactBlobName)}\"`
1521
+ };
1522
+ }
1523
+ throw new Error(`Unsupported binary route: ${path}`);
1524
+ }
1525
+ function fileNameFromContentDisposition(contentDisposition) {
1526
+ if (!contentDisposition) {
1527
+ return null;
1528
+ }
1529
+ const match = /filename=\"?([^\";]+)\"?/i.exec(contentDisposition);
1530
+ if (!match?.[1]) {
1531
+ return null;
1532
+ }
1533
+ return basename(match[1]);
1534
+ }
1535
+ async function handleAuthLogin(options) {
1536
+ const ui = new CliUi("Sign In");
1537
+ const apiBaseUrl = getApiBaseUrl(options.api);
1538
+ ui.info(`API: ${apiBaseUrl}`);
1539
+ ui.info(`Email: ${options.email}`);
1540
+ const result = await ui.step("Authenticating account", async () => postJson(`${apiBaseUrl}/v1/auth/login`, { email: options.email }));
1541
+ await ui.step("Saving local session", async () => {
1542
+ persistCliConfig({
1543
+ apiBaseUrl,
1544
+ authToken: result.token,
1545
+ userEmail: result.user.email
1546
+ });
1547
+ });
1548
+ ui.complete("Sign in complete.");
1549
+ console.log(`signed_in_as=${result.user.email}`);
1550
+ console.log(`account_status=${result.user.accountStatus}`);
1551
+ }
1552
+ async function handleAuthWhoami(options) {
1553
+ const ui = new CliUi("Current User");
1554
+ const apiBaseUrl = getApiBaseUrl(options.api);
1555
+ const result = await ui.step("Loading account", async () => getJson(`${apiBaseUrl}/v1/auth/me`));
1556
+ ui.complete("Account loaded.");
1557
+ console.log(`user_id=${result.user.id}`);
1558
+ console.log(`email=${result.user.email}`);
1559
+ console.log(`account_status=${result.user.accountStatus}`);
1560
+ }
1561
+ async function handleAuthLogout() {
1562
+ const ui = new CliUi("Sign Out");
1563
+ await ui.step("Clearing local session", async () => {
1564
+ persistCliConfig({
1565
+ authToken: undefined,
1566
+ userEmail: undefined
1567
+ });
1568
+ });
1569
+ ui.complete("Sign out complete.");
1570
+ console.log("signed_out=true");
1571
+ }
1572
+ async function recordConsent(apiBaseUrl, input) {
1573
+ await postJson(`${apiBaseUrl}/v1/consents`, input);
1574
+ }
1575
+ async function runCommand(command, args, options) {
1576
+ return new Promise((resolve, reject) => {
1577
+ const child = spawn(command, args, {
1578
+ cwd: options?.cwd,
1579
+ env: options?.env,
1580
+ stdio: options?.inherit ? "inherit" : "pipe"
1581
+ });
1582
+ let stdout = "";
1583
+ let stderr = "";
1584
+ if (!options?.inherit) {
1585
+ if (!child.stdout || !child.stderr) {
1586
+ reject(new Error("Child process streams are unavailable"));
1587
+ return;
1588
+ }
1589
+ child.stdout.on("data", (chunk) => {
1590
+ stdout += chunk.toString("utf8");
1591
+ });
1592
+ child.stderr.on("data", (chunk) => {
1593
+ stderr += chunk.toString("utf8");
1594
+ });
1595
+ }
1596
+ child.on("error", (error) => {
1597
+ reject(error);
1598
+ });
1599
+ child.on("close", (code) => {
1600
+ if (code === 0) {
1601
+ resolve({ stdout, stderr });
1602
+ return;
1603
+ }
1604
+ reject(new CommandExecutionError(`${command} ${args.join(" ")} failed with code ${code}`, stdout, stderr, code));
1605
+ });
1606
+ });
1607
+ }
1608
+ async function ensureFastlaneInstalled() {
1609
+ try {
1610
+ await runCommand("fastlane", ["--version"]);
1611
+ }
1612
+ catch {
1613
+ throw new Error("fastlane is not installed or not on PATH. Install it first: https://docs.fastlane.tools/getting-started/ios/setup/");
1614
+ }
1615
+ }
1616
+ async function hasLocalDistributionIdentity() {
1617
+ try {
1618
+ const result = await runCommand("security", ["find-identity", "-v", "-p", "codesigning"]);
1619
+ return /Apple Distribution:/i.test(result.stdout);
1620
+ }
1621
+ catch {
1622
+ return false;
1623
+ }
1624
+ }
1625
+ async function findNewestLocalProvisioningProfile(bundleId) {
1626
+ const searchDirs = [
1627
+ join(homedir(), "Library", "MobileDevice", "Provisioning Profiles"),
1628
+ join(homedir(), "Downloads")
1629
+ ];
1630
+ let bestPath = null;
1631
+ let bestMtimeMs = 0;
1632
+ for (const dirPath of searchDirs) {
1633
+ if (!existsSync(dirPath)) {
1634
+ continue;
1635
+ }
1636
+ const entries = await readdir(dirPath, { withFileTypes: true });
1637
+ for (const entry of entries) {
1638
+ if (!entry.isFile() || !entry.name.endsWith(".mobileprovision")) {
1639
+ continue;
1640
+ }
1641
+ const profilePath = join(dirPath, entry.name);
1642
+ try {
1643
+ const profileDump = await runCommand("security", ["cms", "-D", "-i", profilePath]);
1644
+ const appIdentifier = /<key>application-identifier<\/key>\s*<string>([^<]+)<\/string>/m.exec(profileDump.stdout)?.[1] ?? "";
1645
+ if (!appIdentifier.endsWith(`.${bundleId}`)) {
1646
+ continue;
1647
+ }
1648
+ const fileStat = await stat(profilePath);
1649
+ if (!bestPath || fileStat.mtimeMs > bestMtimeMs) {
1650
+ bestPath = profilePath;
1651
+ bestMtimeMs = fileStat.mtimeMs;
1652
+ }
1653
+ }
1654
+ catch {
1655
+ continue;
1656
+ }
1657
+ }
1658
+ }
1659
+ if (!bestPath) {
1660
+ return null;
1661
+ }
1662
+ return {
1663
+ path: bestPath,
1664
+ base64: (await readFile(bestPath)).toString("base64")
1665
+ };
1666
+ }
1667
+ async function findFirstFileWithExtensions(baseDir, extensions) {
1668
+ const entries = await readdir(baseDir, { withFileTypes: true });
1669
+ for (const entry of entries) {
1670
+ const fullPath = join(baseDir, entry.name);
1671
+ if (entry.isDirectory()) {
1672
+ const nested = await findFirstFileWithExtensions(fullPath, extensions);
1673
+ if (nested) {
1674
+ return nested;
1675
+ }
1676
+ continue;
1677
+ }
1678
+ for (const extension of extensions) {
1679
+ if (entry.name.toLowerCase().endsWith(extension.toLowerCase())) {
1680
+ return fullPath;
1681
+ }
1682
+ }
1683
+ }
1684
+ return null;
1685
+ }
1686
+ const program = new Command();
1687
+ program
1688
+ .name("gl")
1689
+ .description("Green Line build and submit CLI")
1690
+ .version("1.0.0");
1691
+ const auth = program.command("auth").description("Authenticate CLI user");
1692
+ auth
1693
+ .command("login")
1694
+ .requiredOption("--email <email>", "account email")
1695
+ .option("--api <baseUrl>", "API base URL")
1696
+ .action(handleAuthLogin);
1697
+ auth
1698
+ .command("whoami")
1699
+ .option("--api <baseUrl>", "API base URL")
1700
+ .action(handleAuthWhoami);
1701
+ auth
1702
+ .command("logout")
1703
+ .action(handleAuthLogout);
1704
+ program
1705
+ .command("login")
1706
+ .description("Sign in (friendly alias for `gl auth login`)")
1707
+ .requiredOption("--email <email>", "account email")
1708
+ .option("--api <baseUrl>", "API base URL")
1709
+ .action(handleAuthLogin);
1710
+ program
1711
+ .command("whoami")
1712
+ .description("Show current signed-in account (alias for `gl auth whoami`)")
1713
+ .option("--api <baseUrl>", "API base URL")
1714
+ .action(handleAuthWhoami);
1715
+ program
1716
+ .command("logout")
1717
+ .description("Sign out (alias for `gl auth logout`)")
1718
+ .action(handleAuthLogout);
1719
+ const build = program.command("build").description("Manage build jobs");
1720
+ const buildIos = build.command("ios").description("iOS build commands");
1721
+ buildIos
1722
+ .command("start")
1723
+ .option("--org <orgSlug>", "organization slug (auto-resolved if omitted)")
1724
+ .option("--project <projectSlug>", "project slug (auto-resolved if omitted)")
1725
+ .option("--team-id <teamId>", "Apple Developer Team ID (saved for future builds)")
1726
+ .option("--profile <profile>", "build profile (prod, preview, etc.; defaults to prod)")
1727
+ .option("--commit <sha>", "git commit SHA (auto-resolved from current git HEAD)")
1728
+ .option("--yes", "skip interactive permission confirmation")
1729
+ .option("--api <baseUrl>", "API base URL")
1730
+ .action(async (options) => {
1731
+ ensureSignedIn();
1732
+ const ui = new CliUi("iOS Build Start");
1733
+ const apiBaseUrl = getApiBaseUrl(options.api);
1734
+ const org = inferOrgSlug(options.org);
1735
+ const project = inferProjectSlug(options.project);
1736
+ const team = inferAppleTeamId(options.teamId);
1737
+ const profile = inferBuildProfile(options.profile);
1738
+ const commit = await ui.step("Resolving build inputs", async () => inferCommitSha(options.commit));
1739
+ ui.info(`API: ${apiBaseUrl}`);
1740
+ ui.info(`Organization: ${org.value} (${org.source})`);
1741
+ ui.info(`Project: ${project.value} (${project.source})`);
1742
+ if (team.value) {
1743
+ ui.info(`Apple Team ID: ${team.value} (${team.source})`);
1744
+ }
1745
+ ui.info(`Profile: ${profile.value} (${profile.source})`);
1746
+ ui.info(`Commit: ${commit.value} (${commit.source})`);
1747
+ ui.info("Build execution can take several minutes.");
1748
+ const buildConsentText = `Allow Green Line to create an iOS build for ${org.value}/${project.value} using profile ${profile.value}?`;
1749
+ const buildConsentAccepted = options.yes
1750
+ ? true
1751
+ : await ui.step("Requesting permission for build job", async () => promptForYes(buildConsentText));
1752
+ await ui.step("Recording permission choice", async () => recordConsent(apiBaseUrl, {
1753
+ orgSlug: org.value,
1754
+ projectSlug: project.value,
1755
+ action: "ios_build_start",
1756
+ consentText: buildConsentText,
1757
+ response: buildConsentAccepted ? "accepted" : "declined"
1758
+ }));
1759
+ if (!buildConsentAccepted) {
1760
+ ui.complete("Build cancelled by user.");
1761
+ console.log("build_aborted=true");
1762
+ return;
1763
+ }
1764
+ if (team.value) {
1765
+ persistCliConfig({
1766
+ lastAppleTeamId: team.value
1767
+ });
1768
+ }
1769
+ const result = await ui.step("Creating build job", async () => postJson(`${apiBaseUrl}/v1/builds`, {
1770
+ orgSlug: org.value,
1771
+ projectSlug: project.value,
1772
+ profile: profile.value,
1773
+ commitSha: commit.value
1774
+ }));
1775
+ await ui.step("Saving project defaults", async () => {
1776
+ persistProjectDefaults(org.value, project.value, profile.value);
1777
+ if (team.value) {
1778
+ persistCliConfig({
1779
+ lastAppleTeamId: team.value
1780
+ });
1781
+ }
1782
+ });
1783
+ ui.complete("Build job created.");
1784
+ console.log(`build_id=${result.build.id}`);
1785
+ console.log(`status=${result.build.status}`);
1786
+ console.log(`created_at=${result.build.createdAt}`);
1787
+ });
1788
+ buildIos
1789
+ .command("list")
1790
+ .option("--org <orgSlug>", "organization slug (auto-resolved if omitted)")
1791
+ .option("--project <projectSlug>", "project slug (auto-resolved if omitted)")
1792
+ .option("--limit <count>", "result limit", "20")
1793
+ .option("--api <baseUrl>", "API base URL")
1794
+ .action(async (options) => {
1795
+ const ui = new CliUi("iOS Build List");
1796
+ const apiBaseUrl = getApiBaseUrl(options.api);
1797
+ const org = inferOrgSlug(options.org);
1798
+ const project = inferProjectSlug(options.project);
1799
+ ui.info(`API: ${apiBaseUrl}`);
1800
+ ui.info(`Organization: ${org.value} (${org.source})`);
1801
+ ui.info(`Project: ${project.value} (${project.source})`);
1802
+ ui.info(`Limit: ${options.limit}`);
1803
+ const url = withQuery(`${apiBaseUrl}/v1/builds`, {
1804
+ orgSlug: org.value,
1805
+ projectSlug: project.value,
1806
+ limit: Number(options.limit)
1807
+ });
1808
+ const result = await ui.step("Loading builds", async () => getJson(url));
1809
+ ui.complete("Build list loaded.");
1810
+ for (const item of result.builds) {
1811
+ console.log(`${item.id}\t${item.status}\t${item.profile}\t${item.commitSha}\t${item.createdAt}`);
1812
+ }
1813
+ });
1814
+ buildIos
1815
+ .command("status <buildId>")
1816
+ .option("--api <baseUrl>", "API base URL")
1817
+ .action(async (buildId, options) => {
1818
+ const ui = new CliUi("iOS Build Status");
1819
+ const apiBaseUrl = getApiBaseUrl(options.api);
1820
+ ui.info(`API: ${apiBaseUrl}`);
1821
+ ui.info(`Build ID: ${buildId}`);
1822
+ const result = await ui.step("Loading build status", async () => getJson(`${apiBaseUrl}/v1/builds/${buildId}`));
1823
+ ui.complete("Build status loaded.");
1824
+ console.log(`build_id=${result.build.id}`);
1825
+ console.log(`status=${result.build.status}`);
1826
+ if (result.build.artifactUrl) {
1827
+ console.log(`artifact_url=${result.build.artifactUrl}`);
1828
+ }
1829
+ if (result.build.errorMessage) {
1830
+ console.log(`error=${result.build.errorMessage}`);
1831
+ }
1832
+ console.log(`updated_at=${result.build.updatedAt}`);
1833
+ });
1834
+ buildIos
1835
+ .command("logs <buildId>")
1836
+ .option("--api <baseUrl>", "API base URL")
1837
+ .action(async (buildId, options) => {
1838
+ const ui = new CliUi("iOS Build Logs");
1839
+ const apiBaseUrl = getApiBaseUrl(options.api);
1840
+ ui.info(`API: ${apiBaseUrl}`);
1841
+ ui.info(`Build ID: ${buildId}`);
1842
+ const result = await ui.step("Loading build logs", async () => getJson(`${apiBaseUrl}/v1/builds/${buildId}/logs`));
1843
+ ui.complete("Build logs loaded.");
1844
+ process.stdout.write(result.logs);
1845
+ });
1846
+ buildIos
1847
+ .command("download <buildId>")
1848
+ .option("--out <path>", "output file path")
1849
+ .option("--api <baseUrl>", "API base URL")
1850
+ .action(async (buildId, options) => {
1851
+ const ui = new CliUi("iOS Build Artifact Download");
1852
+ const apiBaseUrl = getApiBaseUrl(options.api);
1853
+ ui.info(`API: ${apiBaseUrl}`);
1854
+ ui.info(`Build ID: ${buildId}`);
1855
+ const buildResult = await ui.step("Loading build metadata", async () => getJson(`${apiBaseUrl}/v1/builds/${buildId}`));
1856
+ if (!buildResult.build.artifactUrl) {
1857
+ throw new Error("Artifact is not available yet. Wait for build status to be `succeeded`.");
1858
+ }
1859
+ const artifactResponse = await ui.step("Downloading artifact", async () => getBinary(`${apiBaseUrl}/v1/builds/${buildId}/artifact`));
1860
+ const inferredName = fileNameFromContentDisposition(artifactResponse.contentDisposition)
1861
+ ?? (() => {
1862
+ try {
1863
+ return basename(new URL(buildResult.build.artifactUrl).pathname);
1864
+ }
1865
+ catch {
1866
+ return null;
1867
+ }
1868
+ })()
1869
+ ?? `build-${buildId}.bin`;
1870
+ const outputPath = options.out ?? inferredName;
1871
+ await ui.step("Writing artifact to disk", async () => {
1872
+ await writeFile(outputPath, artifactResponse.content);
1873
+ });
1874
+ ui.complete("Artifact download complete.");
1875
+ console.log(`build_id=${buildId}`);
1876
+ console.log(`output_path=${outputPath}`);
1877
+ if (artifactResponse.contentType) {
1878
+ console.log(`content_type=${artifactResponse.contentType}`);
1879
+ }
1880
+ console.log(`bytes=${artifactResponse.content.byteLength}`);
1881
+ });
1882
+ const apple = program.command("apple").description("Manage Apple credentials and signing");
1883
+ const appleConnect = apple.command("connect").description("App Store Connect API key credentials");
1884
+ const appleSigning = apple.command("signing").description("iOS signing assets generated via fastlane");
1885
+ apple
1886
+ .command("login")
1887
+ .requiredOption("--apple-id <appleId>", "Apple ID email")
1888
+ .action(async (options) => {
1889
+ const ui = new CliUi("Apple Login");
1890
+ await ui.step("Checking fastlane installation", async () => {
1891
+ await ensureFastlaneInstalled();
1892
+ });
1893
+ ui.info(`Apple ID: ${options.appleId}`);
1894
+ ui.info("If Apple asks for a verification code, enter it in this terminal and press Return.");
1895
+ await ui.step("Starting interactive Apple sign-in (2FA may be required)", async () => {
1896
+ await runCommand("fastlane", ["spaceauth", "-u", options.appleId], { inherit: true });
1897
+ });
1898
+ ui.complete("Apple login flow completed.");
1899
+ console.log("If prompted with FASTLANE_SESSION export text, save it in your shell profile for CI reuse.");
1900
+ });
1901
+ apple
1902
+ .command("bootstrap")
1903
+ .alias("setup")
1904
+ .option("--from-expo", "best-effort automatic migration from Expo project config and local credentials")
1905
+ .option("--auto-key-timeout <seconds>", "wait time for automatic AuthKey_*.p8 import from Downloads", "180")
1906
+ .option("--org <orgSlug>", "organization slug (auto-derived if omitted)")
1907
+ .option("--project <projectSlug>", "project slug (auto-derived if omitted)")
1908
+ .option("--bundle-id <bundleId>", "iOS bundle identifier, e.g. com.acme.app (auto-generated if omitted)")
1909
+ .requiredOption("--apple-id <appleId>", "Apple ID email")
1910
+ .option("--team-id <teamId>", "Apple Developer Team ID")
1911
+ .option("--issuer-id <issuerId>", "App Store Connect issuer ID (UUID)")
1912
+ .option("--key-id <keyId>", "App Store Connect key ID (optional if .p8 file is named AuthKey_<KEY_ID>.p8)")
1913
+ .option("--p8 <file>", "path to App Store Connect .p8 private key")
1914
+ .option("--cert-password <password>", "password used to protect generated .p12 (optional)")
1915
+ .option("--yes", "non-interactive mode: auto-accept Green Line prompts")
1916
+ .option("--api <baseUrl>", "API base URL")
1917
+ .action(async (options) => {
1918
+ ensureSignedIn();
1919
+ const nonInteractive = Boolean(options.yes);
1920
+ const ui = new CliUi("Apple Bootstrap");
1921
+ await ui.step("Checking fastlane installation", async () => {
1922
+ await ensureFastlaneInstalled();
1923
+ });
1924
+ const expoHints = options.fromExpo
1925
+ ? await ui.step("Detecting Expo project metadata", async () => detectExpoProjectHints(process.cwd()))
1926
+ : null;
1927
+ if (expoHints) {
1928
+ ui.info(`Expo migration mode: enabled`);
1929
+ if (expoHints.sourceFiles.length > 0) {
1930
+ ui.info(`Detected config sources: ${expoHints.sourceFiles.join(", ")}`);
1931
+ }
1932
+ }
1933
+ const inferredTeamFromExpo = expoHints?.appleTeamId?.trim() || null;
1934
+ const inferredTeamFromXcode = detectAppleTeamIdFromXcodeProject(process.cwd());
1935
+ const resolvedTeamId = options.teamId?.trim()
1936
+ || inferredTeamFromExpo
1937
+ || inferredTeamFromXcode
1938
+ || null;
1939
+ const derivedOrg = resolvedTeamId
1940
+ ? slugify(resolvedTeamId)
1941
+ : slugify(options.appleId.split("@")[0] ?? "apple-org");
1942
+ const derivedProject = expoHints?.projectSlug ?? defaultProjectSlug();
1943
+ const orgSlug = options.org ?? expoHints?.ownerSlug ?? derivedOrg;
1944
+ const projectSlug = options.project ?? derivedProject;
1945
+ const autoBundleId = `com.${bundleSegment(orgSlug)}.${bundleSegment(projectSlug)}`;
1946
+ const bundleId = options.bundleId
1947
+ ?? process.env.IOS_BUNDLE_ID
1948
+ ?? process.env.EXPO_IOS_BUNDLE_IDENTIFIER
1949
+ ?? expoHints?.bundleId
1950
+ ?? autoBundleId;
1951
+ const appName = expoHints?.appName ?? titleCase(projectSlug);
1952
+ const apiBaseUrl = getApiBaseUrl(options.api);
1953
+ ui.info(`API: ${apiBaseUrl}`);
1954
+ ui.info(`Apple ID: ${options.appleId}`);
1955
+ if (resolvedTeamId) {
1956
+ const teamSource = options.teamId?.trim()
1957
+ ? "--team-id"
1958
+ : inferredTeamFromExpo
1959
+ ? "expo config"
1960
+ : "xcode project";
1961
+ ui.info(`Team ID: ${resolvedTeamId} (${teamSource})`);
1962
+ }
1963
+ ui.info(`Organization: ${orgSlug}`);
1964
+ ui.info(`Project: ${projectSlug}`);
1965
+ ui.info(`Bundle ID: ${bundleId}`);
1966
+ ui.info(`App Name: ${appName}`);
1967
+ ui.info("If Apple asks for a verification code, enter it in this terminal and press Return.");
1968
+ console.log(`using_org=${orgSlug}`);
1969
+ console.log(`using_project=${projectSlug}`);
1970
+ console.log(`using_bundle_id=${bundleId}`);
1971
+ console.log(`using_app_name=${appName}`);
1972
+ if (expoHints) {
1973
+ const pluginPackages = await ui.step("Inspecting Expo plugin package usage", async () => detectExpoPluginPackages(process.cwd()));
1974
+ if (pluginPackages.length > 0) {
1975
+ ui.info(`Expo plugin packages detected: ${pluginPackages.join(", ")}`);
1976
+ }
1977
+ const importPackages = await ui.step("Scanning source imports for Expo packages", async () => detectPackagesFromSourceImports(process.cwd()));
1978
+ if (importPackages.length > 0) {
1979
+ ui.info(`Expo packages detected from source imports: ${importPackages.join(", ")}`);
1980
+ }
1981
+ const requiredExpoPackages = Array.from(new Set([...pluginPackages, ...importPackages])).sort((a, b) => a.localeCompare(b));
1982
+ if (requiredExpoPackages.length > 0) {
1983
+ ui.info(`Total required Expo-related packages: ${requiredExpoPackages.length}`);
1984
+ }
1985
+ const installedDeps = readPackageDependencyNames(process.cwd());
1986
+ const missingPluginPackages = requiredExpoPackages.filter((pkg) => !installedDeps.has(pkg));
1987
+ if (missingPluginPackages.length > 0) {
1988
+ ui.info(`Missing Expo plugin packages: ${missingPluginPackages.join(", ")}`);
1989
+ const packageSyncConsentText = [
1990
+ "Allow Green Line to install missing Expo-related packages detected from app config/imports and refresh iOS pods?",
1991
+ `Packages: ${missingPluginPackages.join(", ")}`
1992
+ ].join(" ");
1993
+ const packageSyncAccepted = nonInteractive
1994
+ ? true
1995
+ : await ui.step("Requesting permission for Expo package sync", async () => promptForYes(packageSyncConsentText));
1996
+ await ui.step("Recording permission choice", async () => recordConsent(apiBaseUrl, {
1997
+ orgSlug,
1998
+ projectSlug,
1999
+ action: "expo_package_sync",
2000
+ consentText: packageSyncConsentText,
2001
+ response: packageSyncAccepted ? "accepted" : "declined"
2002
+ }));
2003
+ if (packageSyncAccepted) {
2004
+ await ui.step("Installing missing Expo plugin packages", async () => {
2005
+ await runCommand("npx", ["expo", "install", ...missingPluginPackages], { inherit: true, cwd: process.cwd() });
2006
+ });
2007
+ if (existsSync(join(process.cwd(), "ios"))) {
2008
+ await ui.step("Refreshing CocoaPods for iOS project", async () => {
2009
+ await runCommand("npx", ["pod-install", "ios"], { inherit: true, cwd: process.cwd() });
2010
+ });
2011
+ }
2012
+ }
2013
+ else {
2014
+ ui.info("Continuing without Expo package sync. Build may fail if required native modules are missing.");
2015
+ }
2016
+ }
2017
+ }
2018
+ const consentText = [
2019
+ `Allow Green Line to create/use Apple App ID (${bundleId}),`,
2020
+ "generate signing certificate and provisioning profile,",
2021
+ "and store encrypted signing assets for this project?"
2022
+ ].join(" ");
2023
+ const accepted = nonInteractive
2024
+ ? true
2025
+ : await ui.step("Requesting permission for Apple signing setup", async () => promptForYes(consentText));
2026
+ await ui.step("Recording permission choice", async () => recordConsent(apiBaseUrl, {
2027
+ orgSlug,
2028
+ projectSlug,
2029
+ action: "apple_signing_bootstrap",
2030
+ consentText,
2031
+ response: accepted ? "accepted" : "declined"
2032
+ }));
2033
+ if (!accepted) {
2034
+ ui.complete("Apple bootstrap cancelled by user.");
2035
+ console.log("bootstrap_aborted=true");
2036
+ return;
2037
+ }
2038
+ const workspace = await ui.step("Preparing temporary workspace", async () => mkdtemp(join(tmpdir(), "gl-fastlane-")));
2039
+ const certDir = join(workspace, "certs");
2040
+ const profileDir = join(workspace, "profiles");
2041
+ try {
2042
+ let selectedDistributionCertId = null;
2043
+ await ui.step("Creating workspace folders", async () => {
2044
+ await mkdir(certDir, { recursive: true });
2045
+ await mkdir(profileDir, { recursive: true });
2046
+ });
2047
+ const produceArgs = [
2048
+ "run",
2049
+ "produce",
2050
+ `username:${options.appleId}`,
2051
+ `app_identifier:${bundleId}`,
2052
+ `app_name:${appName}`,
2053
+ "skip_itc:true"
2054
+ ];
2055
+ if (resolvedTeamId) {
2056
+ produceArgs.push(`team_id:${resolvedTeamId}`);
2057
+ }
2058
+ await ui.step("Ensuring Apple App ID exists", async () => {
2059
+ try {
2060
+ await runCommand("fastlane", produceArgs, { inherit: true });
2061
+ }
2062
+ catch (error) {
2063
+ if (error instanceof CommandExecutionError) {
2064
+ const output = `${error.stdout}\n${error.stderr}`;
2065
+ if (/already exists/i.test(output)
2066
+ || /cannot be registered to your account/i.test(output)
2067
+ || /is not available/i.test(output)) {
2068
+ ui.info("App ID already exists, continuing.");
2069
+ return;
2070
+ }
2071
+ throw new Error(`${error.message}\n${output.trim()}`);
2072
+ }
2073
+ throw error;
2074
+ }
2075
+ });
2076
+ const certArgs = [
2077
+ "run",
2078
+ "cert",
2079
+ `username:${options.appleId}`,
2080
+ "development:false",
2081
+ "generate_apple_certs:true",
2082
+ `output_path:${certDir}`,
2083
+ "force:false"
2084
+ ];
2085
+ if (resolvedTeamId) {
2086
+ certArgs.push(`team_id:${resolvedTeamId}`);
2087
+ }
2088
+ if (options.certPassword) {
2089
+ certArgs.push(`keychain_password:${options.certPassword}`);
2090
+ }
2091
+ await ui.step("Generating or syncing distribution certificate", async () => {
2092
+ try {
2093
+ const certResult = await runCommand("fastlane", certArgs, { inherit: true });
2094
+ const combinedOutput = `${certResult.stdout}\n${certResult.stderr}`;
2095
+ selectedDistributionCertId = /Result:\s*([A-Z0-9]{8,})/m.exec(combinedOutput)?.[1] ?? null;
2096
+ }
2097
+ catch (error) {
2098
+ if (error instanceof CommandExecutionError) {
2099
+ const output = `${error.stdout}\n${error.stderr}`;
2100
+ if (/maximum number of available Distribution certificates/i.test(output)) {
2101
+ if (await hasLocalDistributionIdentity()) {
2102
+ ui.info("Apple blocked creating another distribution certificate; using existing local distribution identity.");
2103
+ return;
2104
+ }
2105
+ }
2106
+ }
2107
+ throw error;
2108
+ }
2109
+ });
2110
+ const sighArgs = [
2111
+ "run",
2112
+ "sigh",
2113
+ `username:${options.appleId}`,
2114
+ `app_identifier:${bundleId}`,
2115
+ "skip_install:true",
2116
+ "include_all_certificates:true",
2117
+ `output_path:${profileDir}`,
2118
+ "force:true"
2119
+ ];
2120
+ if (resolvedTeamId) {
2121
+ sighArgs.push(`team_id:${resolvedTeamId}`);
2122
+ }
2123
+ if (selectedDistributionCertId) {
2124
+ sighArgs.push(`cert_id:${selectedDistributionCertId}`);
2125
+ }
2126
+ await ui.step("Generating or syncing provisioning profile", async () => {
2127
+ await runCommand("fastlane", sighArgs, { inherit: true });
2128
+ });
2129
+ const { p12Path, provisioningPath } = await ui.step("Finding generated signing files", async () => {
2130
+ const p12FilePath = await findFirstFileWithExtensions(certDir, [".p12"]);
2131
+ const profileFilePath = await findFirstFileWithExtensions(profileDir, [".mobileprovision", ".provisionprofile"]);
2132
+ if (!profileFilePath) {
2133
+ throw new Error(`No provisioning profile was generated in ${profileDir}`);
2134
+ }
2135
+ return {
2136
+ p12Path: p12FilePath,
2137
+ provisioningPath: profileFilePath
2138
+ };
2139
+ });
2140
+ const { distributionCertP12Base64, provisioningProfileBase64 } = await ui.step("Reading signing files", async () => ({
2141
+ distributionCertP12Base64: p12Path ? (await readFile(p12Path)).toString("base64") : undefined,
2142
+ provisioningProfileBase64: (await readFile(provisioningPath)).toString("base64")
2143
+ }));
2144
+ const result = await ui.step("Uploading encrypted signing assets", async () => postJson(`${apiBaseUrl}/v1/apple/signing-assets`, {
2145
+ orgSlug,
2146
+ projectSlug,
2147
+ bundleId,
2148
+ distributionCertP12Base64,
2149
+ distributionCertPassword: options.certPassword,
2150
+ provisioningProfileBase64
2151
+ }));
2152
+ const managedConnection = await ui.step("Ensuring managed submission connection", async () => {
2153
+ const existing = localState.appleConnections.find((item) => item.orgSlug === orgSlug && item.projectSlug === projectSlug);
2154
+ if (existing) {
2155
+ if (!isManagedAscConnection(existing)) {
2156
+ const { privateKeyPem: _privateKeyPem, ...publicConnection } = existing;
2157
+ return publicConnection;
2158
+ }
2159
+ ui.info("Existing App Store Connect key is placeholder-managed. Attempting to replace with your real key.");
2160
+ }
2161
+ const envP8Path = process.env.ASC_P8_PATH;
2162
+ const envIssuerId = process.env.ASC_ISSUER_ID;
2163
+ const envKeyId = process.env.ASC_KEY_ID;
2164
+ let discoveredP8Path = options.p8?.trim() || null;
2165
+ let discoveredIssuerId = options.issuerId?.trim() || (envIssuerId ?? null);
2166
+ let discoveredKeyId = options.keyId?.trim() || (envKeyId ?? null);
2167
+ if (discoveredP8Path && !existsSync(discoveredP8Path)) {
2168
+ throw new Error(`Provided .p8 file does not exist: ${discoveredP8Path}`);
2169
+ }
2170
+ if (!discoveredP8Path && envP8Path && existsSync(envP8Path)) {
2171
+ discoveredP8Path = envP8Path;
2172
+ }
2173
+ if (!discoveredP8Path && options.fromExpo) {
2174
+ const downloadsDir = join(homedir(), "Downloads");
2175
+ const currentNewest = await findNewestAuthKeyP8(downloadsDir);
2176
+ if (currentNewest) {
2177
+ discoveredP8Path = currentNewest.path;
2178
+ }
2179
+ if (!discoveredP8Path) {
2180
+ const connectHomeUrl = "https://appstoreconnect.apple.com/";
2181
+ const connectApiUrl = "https://appstoreconnect.apple.com/access/api";
2182
+ if (!nonInteractive) {
2183
+ const alreadySignedIn = await ui.step("Confirming App Store Connect browser sign-in", async () => promptForYes([
2184
+ "Are you already signed in to App Store Connect in your browser?",
2185
+ "If not, choose n and Green Line will show the exact URLs and where to click."
2186
+ ].join(" ")));
2187
+ if (!alreadySignedIn) {
2188
+ ui.info(`Sign in first at: ${connectHomeUrl}`);
2189
+ ui.info(`Then open: ${connectApiUrl}`);
2190
+ ui.info("Navigation: Users and Access > Integrations > App Store Connect API > Create API Key.");
2191
+ }
2192
+ }
2193
+ else {
2194
+ ui.info(`Ensure you are signed in to App Store Connect: ${connectHomeUrl}`);
2195
+ ui.info(`If needed, go directly to API Keys: ${connectApiUrl}`);
2196
+ }
2197
+ const allowKeyAssist = nonInteractive
2198
+ ? true
2199
+ : await promptForYes([
2200
+ "No existing App Store Connect key file was found.",
2201
+ "Green Line can open the Apple keys page and auto-import AuthKey_*.p8 from Downloads.",
2202
+ "Proceed with assisted automatic key import?"
2203
+ ].join(" "));
2204
+ if (allowKeyAssist) {
2205
+ try {
2206
+ await runCommand("open", [connectHomeUrl]);
2207
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, 500));
2208
+ await runCommand("open", [connectApiUrl]);
2209
+ ui.info("Opened App Store Connect sign-in and API keys pages in browser.");
2210
+ }
2211
+ catch {
2212
+ ui.info("Could not auto-open browser.");
2213
+ }
2214
+ ui.info(`Fallback URL (sign-in): ${connectHomeUrl}`);
2215
+ ui.info(`Fallback URL (API keys): ${connectApiUrl}`);
2216
+ ui.info("Sign in to App Store Connect, open Users and Access > Integrations > App Store Connect API, create a key, and download AuthKey_*.p8.");
2217
+ ui.info("Waiting for downloaded AuthKey_*.p8 in ~/Downloads...");
2218
+ const timeoutSeconds = Math.max(30, Number(options.autoKeyTimeout ?? "180"));
2219
+ const awaited = await waitForNewAuthKeyP8(downloadsDir, timeoutSeconds * 1000, currentNewest?.mtimeMs ?? 0);
2220
+ if (awaited) {
2221
+ discoveredP8Path = awaited.path;
2222
+ }
2223
+ }
2224
+ }
2225
+ }
2226
+ if (discoveredP8Path) {
2227
+ const privateKeyPem = await readFile(discoveredP8Path, "utf8");
2228
+ if (!isAscPrivateKeyPem(privateKeyPem)) {
2229
+ throw new Error([
2230
+ `The file passed as --p8 is not an App Store Connect API private key: ${discoveredP8Path}`,
2231
+ "Expected a file named like AuthKey_<KEY_ID>.p8 with PKCS#8 PEM private-key content.",
2232
+ "Do not pass a provisioning profile (.mobileprovision) or certificate file here."
2233
+ ].join("\n"));
2234
+ }
2235
+ const inferredKeyId = inferKeyIdFromP8Path(discoveredP8Path);
2236
+ const keyId = discoveredKeyId ?? inferredKeyId;
2237
+ if (!keyId) {
2238
+ throw new Error("Could not infer App Store Connect key ID from .p8 filename. Rename it to AuthKey_<KEY_ID>.p8 or pass ASC_KEY_ID.");
2239
+ }
2240
+ if (!discoveredIssuerId) {
2241
+ const fromExistingState = localState.appleConnections.find((item) => (item.orgSlug === orgSlug
2242
+ && item.projectSlug === projectSlug
2243
+ && !isManagedAscConnection(item)
2244
+ && extractUuid(item.issuerId)));
2245
+ if (fromExistingState) {
2246
+ discoveredIssuerId = fromExistingState.issuerId;
2247
+ ui.info("Reused issuer ID from existing local App Store Connect connection.");
2248
+ }
2249
+ }
2250
+ if (!discoveredIssuerId) {
2251
+ const clipboardIssuer = await tryReadIssuerIdFromClipboard();
2252
+ if (clipboardIssuer) {
2253
+ discoveredIssuerId = clipboardIssuer;
2254
+ ui.info("Detected issuer ID from clipboard.");
2255
+ }
2256
+ }
2257
+ if (!discoveredIssuerId) {
2258
+ const browserIssuer = await tryReadIssuerIdFromBrowser();
2259
+ if (browserIssuer) {
2260
+ discoveredIssuerId = browserIssuer;
2261
+ ui.info("Detected issuer ID from open App Store Connect browser tab.");
2262
+ }
2263
+ }
2264
+ if (!discoveredIssuerId) {
2265
+ ui.info("Issuer ID is required for App Store Connect API keys and was not detected from environment.");
2266
+ ui.info("Find it in App Store Connect > Users and Access > Integrations > App Store Connect API.");
2267
+ if (nonInteractive) {
2268
+ throw new Error("Missing Issuer ID in non-interactive mode. Provide --issuer-id or set ASC_ISSUER_ID.");
2269
+ }
2270
+ discoveredIssuerId = await promptForText("Enter Issuer ID (UUID): ");
2271
+ }
2272
+ if (!discoveredIssuerId) {
2273
+ throw new Error("Missing Issuer ID. Re-run and provide ASC_ISSUER_ID, or enter it when prompted.");
2274
+ }
2275
+ if (!isUuid(discoveredIssuerId)) {
2276
+ throw new Error("Issuer ID must be a UUID from App Store Connect (Users and Access > Integrations > App Store Connect API). Do not use the Key ID as Issuer ID.");
2277
+ }
2278
+ const imported = await postJson(`${apiBaseUrl}/v1/apple/connections`, {
2279
+ orgSlug,
2280
+ projectSlug,
2281
+ issuerId: discoveredIssuerId,
2282
+ keyId,
2283
+ privateKeyPem
2284
+ });
2285
+ ui.info(`Submission key configured from ${discoveredP8Path}`);
2286
+ return imported.connection;
2287
+ }
2288
+ throw new Error([
2289
+ "No App Store Connect API key (.p8) is configured.",
2290
+ "To continue: sign in to https://appstoreconnect.apple.com/access/api, create a key, download AuthKey_*.p8, then run:",
2291
+ "gl apple connect init --issuer-id <ISSUER_ID> --key-id <KEY_ID> --p8 <path-to-AuthKey_XXXX.p8>",
2292
+ "Or rerun with --from-expo and answer y when prompted for assisted key import."
2293
+ ].join("\n"));
2294
+ });
2295
+ await ui.step("Saving project defaults", async () => {
2296
+ persistProjectDefaults(orgSlug, projectSlug);
2297
+ if (resolvedTeamId) {
2298
+ persistCliConfig({
2299
+ lastAppleTeamId: resolvedTeamId
2300
+ });
2301
+ }
2302
+ });
2303
+ ui.complete("Apple bootstrap complete.");
2304
+ console.log(`signing_assets_id=${result.signingAssets.id}`);
2305
+ console.log(`org=${result.signingAssets.orgSlug}`);
2306
+ console.log(`project=${result.signingAssets.projectSlug}`);
2307
+ console.log(`bundle_id=${result.signingAssets.bundleId}`);
2308
+ console.log(`connection_id=${managedConnection.id}`);
2309
+ console.log(`updated_at=${result.signingAssets.updatedAt}`);
2310
+ }
2311
+ finally {
2312
+ await rm(workspace, { recursive: true, force: true });
2313
+ }
2314
+ });
2315
+ appleConnect
2316
+ .command("init")
2317
+ .alias("save-key")
2318
+ .option("--org <orgSlug>", "organization slug (auto-resolved if omitted)")
2319
+ .option("--project <projectSlug>", "project slug (auto-resolved if omitted)")
2320
+ .requiredOption("--issuer-id <issuerId>", "App Store Connect issuer ID")
2321
+ .requiredOption("--key-id <keyId>", "App Store Connect key ID")
2322
+ .requiredOption("--p8 <file>", "path to .p8 private key")
2323
+ .option("--api <baseUrl>", "API base URL")
2324
+ .action(async (options) => {
2325
+ ensureSignedIn();
2326
+ const ui = new CliUi("Apple Connect Key Setup");
2327
+ const apiBaseUrl = getApiBaseUrl(options.api);
2328
+ const org = inferOrgSlug(options.org);
2329
+ const project = inferProjectSlug(options.project);
2330
+ ui.info(`API: ${apiBaseUrl}`);
2331
+ ui.info(`Organization: ${org.value} (${org.source})`);
2332
+ ui.info(`Project: ${project.value} (${project.source})`);
2333
+ ui.info(`Key ID: ${options.keyId}`);
2334
+ ui.info(`Issuer ID: ${options.issuerId}`);
2335
+ if (!isUuid(options.issuerId)) {
2336
+ throw new Error("--issuer-id must be a UUID from App Store Connect (Users and Access > Integrations > App Store Connect API). Do not pass the Key ID here.");
2337
+ }
2338
+ const consentText = [
2339
+ `Allow Green Line to store an encrypted App Store Connect API key`,
2340
+ `for ${org.value}/${project.value}?`
2341
+ ].join(" ");
2342
+ const accepted = await ui.step("Requesting permission for API key storage", async () => promptForYes(consentText));
2343
+ await ui.step("Recording permission choice", async () => recordConsent(apiBaseUrl, {
2344
+ orgSlug: org.value,
2345
+ projectSlug: project.value,
2346
+ action: "apple_connect_api_key_store",
2347
+ consentText,
2348
+ response: accepted ? "accepted" : "declined"
2349
+ }));
2350
+ if (!accepted) {
2351
+ ui.complete("Apple Connect key setup cancelled by user.");
2352
+ console.log("connect_init_aborted=true");
2353
+ return;
2354
+ }
2355
+ const privateKeyPem = await ui.step("Reading App Store Connect private key", async () => {
2356
+ const contents = await readFile(options.p8, "utf8");
2357
+ if (!isAscPrivateKeyPem(contents)) {
2358
+ throw new Error([
2359
+ `--p8 must point to an App Store Connect API key file (AuthKey_<KEY_ID>.p8): ${options.p8}`,
2360
+ "Expected PKCS#8 PEM content with standard private-key header and footer delimiters.",
2361
+ "Do not pass provisioning profiles (.mobileprovision) or certificate files."
2362
+ ].join("\n"));
2363
+ }
2364
+ return contents;
2365
+ });
2366
+ const result = await ui.step("Uploading encrypted App Store Connect credentials", async () => postJson(`${apiBaseUrl}/v1/apple/connections`, {
2367
+ orgSlug: org.value,
2368
+ projectSlug: project.value,
2369
+ issuerId: options.issuerId,
2370
+ keyId: options.keyId,
2371
+ privateKeyPem
2372
+ }));
2373
+ await ui.step("Saving project defaults", async () => {
2374
+ persistProjectDefaults(org.value, project.value);
2375
+ });
2376
+ ui.complete("Apple Connect key setup complete.");
2377
+ console.log(`connection_id=${result.connection.id}`);
2378
+ console.log(`org=${result.connection.orgSlug}`);
2379
+ console.log(`project=${result.connection.projectSlug}`);
2380
+ console.log(`key_id=${result.connection.keyId}`);
2381
+ console.log(`updated_at=${result.connection.updatedAt}`);
2382
+ });
2383
+ appleConnect
2384
+ .command("status")
2385
+ .option("--org <orgSlug>", "organization slug (auto-resolved if omitted)")
2386
+ .option("--project <projectSlug>", "project slug (auto-resolved if omitted)")
2387
+ .option("--api <baseUrl>", "API base URL")
2388
+ .action(async (options) => {
2389
+ const apiBaseUrl = getApiBaseUrl(options.api);
2390
+ const org = inferOrgSlug(options.org);
2391
+ const project = inferProjectSlug(options.project);
2392
+ const url = withQuery(`${apiBaseUrl}/v1/apple/connections`, {
2393
+ orgSlug: org.value,
2394
+ projectSlug: project.value
2395
+ });
2396
+ const result = await getJson(url);
2397
+ console.log(`connection_id=${result.connection.id}`);
2398
+ console.log(`issuer_id=${result.connection.issuerId}`);
2399
+ console.log(`key_id=${result.connection.keyId}`);
2400
+ console.log(`has_private_key=${result.connection.hasPrivateKey}`);
2401
+ console.log(`updated_at=${result.connection.updatedAt}`);
2402
+ });
2403
+ appleSigning
2404
+ .command("status")
2405
+ .option("--org <orgSlug>", "organization slug (auto-resolved if omitted)")
2406
+ .option("--project <projectSlug>", "project slug (auto-resolved if omitted)")
2407
+ .option("--bundle-id <bundleId>", "bundle identifier (auto-resolved if omitted)")
2408
+ .option("--api <baseUrl>", "API base URL")
2409
+ .action(async (options) => {
2410
+ const apiBaseUrl = getApiBaseUrl(options.api);
2411
+ const org = inferOrgSlug(options.org);
2412
+ const project = inferProjectSlug(options.project);
2413
+ const bundleId = inferBundleId(options.bundleId, org.value, project.value);
2414
+ const url = withQuery(`${apiBaseUrl}/v1/apple/signing-assets`, {
2415
+ orgSlug: org.value,
2416
+ projectSlug: project.value,
2417
+ bundleId: bundleId.value
2418
+ });
2419
+ const result = await getJson(url);
2420
+ console.log(`signing_assets_id=${result.signingAssets.id}`);
2421
+ console.log(`bundle_id=${result.signingAssets.bundleId}`);
2422
+ console.log(`has_distribution_cert=${result.signingAssets.hasDistributionCert}`);
2423
+ console.log(`has_provisioning_profile=${result.signingAssets.hasProvisioningProfile}`);
2424
+ console.log(`updated_at=${result.signingAssets.updatedAt}`);
2425
+ });
2426
+ const migrate = program.command("migrate").description("Migration helpers");
2427
+ migrate
2428
+ .command("expo")
2429
+ .description("best-effort automatic migration from Expo project config and local credentials")
2430
+ .requiredOption("--apple-id <appleId>", "Apple ID email")
2431
+ .option("--org <orgSlug>", "organization slug (optional override)")
2432
+ .option("--project <projectSlug>", "project slug (optional override)")
2433
+ .option("--bundle-id <bundleId>", "bundle identifier (optional override)")
2434
+ .option("--team-id <teamId>", "Apple Developer Team ID")
2435
+ .option("--issuer-id <issuerId>", "App Store Connect issuer ID (UUID)")
2436
+ .option("--key-id <keyId>", "App Store Connect key ID")
2437
+ .option("--p8 <file>", "path to App Store Connect .p8 private key")
2438
+ .option("--cert-password <password>", "password used to protect generated .p12 (optional)")
2439
+ .option("--auto-key-timeout <seconds>", "wait time for automatic AuthKey_*.p8 import from Downloads", "180")
2440
+ .option("--yes", "non-interactive mode: auto-accept Green Line prompts")
2441
+ .option("--api <baseUrl>", "API base URL")
2442
+ .action(async (options) => {
2443
+ const args = [
2444
+ process.argv[1],
2445
+ "apple",
2446
+ "setup",
2447
+ "--from-expo",
2448
+ "--apple-id",
2449
+ options.appleId,
2450
+ "--auto-key-timeout",
2451
+ options.autoKeyTimeout
2452
+ ];
2453
+ if (options.org) {
2454
+ args.push("--org", options.org);
2455
+ }
2456
+ if (options.project) {
2457
+ args.push("--project", options.project);
2458
+ }
2459
+ if (options.bundleId) {
2460
+ args.push("--bundle-id", options.bundleId);
2461
+ }
2462
+ if (options.teamId) {
2463
+ args.push("--team-id", options.teamId);
2464
+ }
2465
+ if (options.issuerId) {
2466
+ args.push("--issuer-id", options.issuerId);
2467
+ }
2468
+ if (options.keyId) {
2469
+ args.push("--key-id", options.keyId);
2470
+ }
2471
+ if (options.p8) {
2472
+ args.push("--p8", options.p8);
2473
+ }
2474
+ if (options.certPassword) {
2475
+ args.push("--cert-password", options.certPassword);
2476
+ }
2477
+ if (options.yes) {
2478
+ args.push("--yes");
2479
+ }
2480
+ if (options.api) {
2481
+ args.push("--api", options.api);
2482
+ }
2483
+ await runCommand(process.execPath, args, { inherit: true });
2484
+ });
2485
+ const submit = program.command("submit").description("Manage submission jobs");
2486
+ const submitIos = submit.command("ios").description("iOS submission commands");
2487
+ submitIos
2488
+ .command("preflight")
2489
+ .description("run submission preflight checks before uploading")
2490
+ .option("--build <buildId>", "build ID (omit to choose from recent successful builds)")
2491
+ .option("--ipa <path>", "path to an existing .ipa file to validate")
2492
+ .option("--org <orgSlug>", "organization slug (used when selecting build)")
2493
+ .option("--project <projectSlug>", "project slug (used when selecting build)")
2494
+ .option("--api <baseUrl>", "API base URL")
2495
+ .action(async (options) => {
2496
+ ensureSignedIn();
2497
+ const ui = new CliUi("iOS Submission Preflight");
2498
+ const apiBaseUrl = getApiBaseUrl(options.api);
2499
+ let buildId = options.build;
2500
+ let ipaPath;
2501
+ let orgSlug = inferOrgSlug(options.org).value;
2502
+ let projectSlug = inferProjectSlug(options.project).value;
2503
+ const checks = [];
2504
+ const addCheck = (name, ok, detail) => {
2505
+ checks.push({ name, ok, detail });
2506
+ ui.info(`${ok ? "PASS" : "FAIL"}: ${name} — ${detail}`);
2507
+ };
2508
+ ui.info(`API: ${apiBaseUrl}`);
2509
+ if (options.ipa) {
2510
+ ipaPath = await ui.step("Validating IPA path", async () => normalizeIpaPath(options.ipa));
2511
+ }
2512
+ if (!buildId && !ipaPath) {
2513
+ const filterOrg = options.org;
2514
+ const filterProject = options.project;
2515
+ const succeededBuilds = await ui.step("Loading recent builds", async () => listRecentSucceededBuilds({
2516
+ orgSlug: filterOrg,
2517
+ projectSlug: filterProject,
2518
+ limit: 25
2519
+ }));
2520
+ if (succeededBuilds.length === 0) {
2521
+ throw new Error("No succeeded builds found. Run `gl build ios start` first.");
2522
+ }
2523
+ const selectedBuild = await ui.step("Selecting build", async () => promptForBuildSelection(succeededBuilds));
2524
+ buildId = selectedBuild.id;
2525
+ orgSlug = selectedBuild.orgSlug;
2526
+ projectSlug = selectedBuild.projectSlug;
2527
+ }
2528
+ let buildRecord;
2529
+ if (buildId) {
2530
+ buildRecord = localState.builds.find((item) => item.id === buildId);
2531
+ if (!buildRecord) {
2532
+ throw new Error(`Build not found: ${buildId}`);
2533
+ }
2534
+ orgSlug = buildRecord.orgSlug;
2535
+ projectSlug = buildRecord.projectSlug;
2536
+ if (!ipaPath) {
2537
+ const artifactPath = localArtifactPathFromBuild(buildRecord);
2538
+ if (artifactPath) {
2539
+ ipaPath = artifactPath;
2540
+ }
2541
+ }
2542
+ }
2543
+ ui.info(`Organization: ${orgSlug}`);
2544
+ ui.info(`Project: ${projectSlug}`);
2545
+ if (buildId) {
2546
+ ui.info(`Build ID: ${buildId}`);
2547
+ }
2548
+ if (ipaPath) {
2549
+ ui.info(`IPA Path: ${ipaPath}`);
2550
+ }
2551
+ await ui.step("Checking App Store Connect credentials", async () => {
2552
+ const connection = localState.appleConnections.find((item) => item.orgSlug === orgSlug && item.projectSlug === projectSlug);
2553
+ addCheck("ASC connection exists", Boolean(connection), connection
2554
+ ? `key_id=${connection.keyId}`
2555
+ : `Run: gl apple connect init --org ${orgSlug} --project ${projectSlug} --issuer-id <UUID> --key-id <KEY_ID> --p8 <AuthKey.p8>`);
2556
+ if (!connection) {
2557
+ return;
2558
+ }
2559
+ addCheck("Issuer ID format", isUuid(connection.issuerId), connection.issuerId);
2560
+ addCheck("Private key PEM format", isAscPrivateKeyPem(connection.privateKeyPem), "Expected BEGIN/END PRIVATE KEY markers");
2561
+ addCheck("Key ID format", !isUuid(connection.keyId), connection.keyId);
2562
+ });
2563
+ await ui.step("Checking IPA artifact", async () => {
2564
+ addCheck("IPA path resolved", Boolean(ipaPath), ipaPath ?? "No IPA path found for selected build");
2565
+ if (!ipaPath) {
2566
+ return;
2567
+ }
2568
+ const exists = existsSync(ipaPath);
2569
+ addCheck("IPA file exists", exists, ipaPath);
2570
+ if (!exists) {
2571
+ return;
2572
+ }
2573
+ const ipaStat = await stat(ipaPath);
2574
+ addCheck("IPA file non-empty", ipaStat.size > 0, `${ipaStat.size} bytes`);
2575
+ });
2576
+ await ui.step("Checking app icon and iOS metadata", async () => {
2577
+ const iconPath = findAppStoreIconPath(process.cwd());
2578
+ addCheck("App Store icon found", Boolean(iconPath), iconPath ?? "Could not find AppIcon.appiconset (run from app root)");
2579
+ if (iconPath) {
2580
+ try {
2581
+ const alphaResult = await runCommand("sips", ["-g", "hasAlpha", iconPath]);
2582
+ const hasAlpha = /hasAlpha:\s*yes/i.test(alphaResult.stdout);
2583
+ addCheck("App Store icon has no alpha", !hasAlpha, iconPath);
2584
+ }
2585
+ catch {
2586
+ addCheck("App Store icon has no alpha", false, `Could not inspect icon alpha: ${iconPath}`);
2587
+ }
2588
+ }
2589
+ const iosDir = existsSync(join(process.cwd(), "ios")) ? join(process.cwd(), "ios") : process.cwd();
2590
+ const infoPlistPath = findFirstFileNamed(iosDir, "Info.plist", 8);
2591
+ addCheck("Info.plist found", Boolean(infoPlistPath), infoPlistPath ?? "Could not locate Info.plist in iOS project");
2592
+ if (!infoPlistPath) {
2593
+ return;
2594
+ }
2595
+ try {
2596
+ const version = (await runCommand("/usr/libexec/PlistBuddy", ["-c", "Print :CFBundleShortVersionString", infoPlistPath])).stdout.trim();
2597
+ addCheck("CFBundleShortVersionString set", version.length > 0, version || "empty");
2598
+ }
2599
+ catch {
2600
+ addCheck("CFBundleShortVersionString set", false, "Missing or unreadable");
2601
+ }
2602
+ try {
2603
+ const buildNumber = (await runCommand("/usr/libexec/PlistBuddy", ["-c", "Print :CFBundleVersion", infoPlistPath])).stdout.trim();
2604
+ addCheck("CFBundleVersion set", buildNumber.length > 0, buildNumber || "empty");
2605
+ }
2606
+ catch {
2607
+ addCheck("CFBundleVersion set", false, "Missing or unreadable");
2608
+ }
2609
+ });
2610
+ await ui.step("Checking provisioning profile freshness", async () => {
2611
+ const signingAsset = localState.appleSigningAssets
2612
+ .filter((item) => item.orgSlug === orgSlug && item.projectSlug === projectSlug)
2613
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0];
2614
+ const expectedBundleId = signingAsset?.bundleId ?? inferBundleId(undefined, orgSlug, projectSlug).value;
2615
+ const localProfile = await findNewestLocalProvisioningProfile(expectedBundleId);
2616
+ addCheck("Provisioning profile present", Boolean(localProfile), localProfile?.path ?? `No local profile found for ${expectedBundleId}`);
2617
+ if (!localProfile) {
2618
+ return;
2619
+ }
2620
+ try {
2621
+ const profileDump = await runCommand("security", ["cms", "-D", "-i", localProfile.path]);
2622
+ const expirationRaw = /<key>ExpirationDate<\/key>\s*<date>([^<]+)<\/date>/m.exec(profileDump.stdout)?.[1] ?? null;
2623
+ if (!expirationRaw) {
2624
+ addCheck("Provisioning profile not expired", false, "Could not read ExpirationDate");
2625
+ return;
2626
+ }
2627
+ const expirationDate = new Date(expirationRaw);
2628
+ const valid = Number.isFinite(expirationDate.getTime()) && expirationDate.getTime() > Date.now();
2629
+ addCheck("Provisioning profile not expired", valid, `expires ${expirationDate.toISOString()}`);
2630
+ }
2631
+ catch {
2632
+ addCheck("Provisioning profile not expired", false, "Could not parse provisioning profile");
2633
+ }
2634
+ });
2635
+ const failedChecks = checks.filter((item) => !item.ok);
2636
+ const passedChecks = checks.length - failedChecks.length;
2637
+ console.log(`checks_total=${checks.length}`);
2638
+ console.log(`checks_passed=${passedChecks}`);
2639
+ console.log(`checks_failed=${failedChecks.length}`);
2640
+ if (failedChecks.length > 0) {
2641
+ throw new Error(`Preflight failed (${failedChecks.length} check${failedChecks.length === 1 ? "" : "s"} failed). Fix the reported checks and rerun.`);
2642
+ }
2643
+ ui.complete("Preflight passed. Submission checks are green.");
2644
+ });
2645
+ submitIos
2646
+ .command("start")
2647
+ .option("--build <buildId>", "build ID (omit to choose from recent successful builds)")
2648
+ .option("--ipa <path>", "path to an existing .ipa file to submit directly")
2649
+ .option("--org <orgSlug>", "organization slug (used when selecting build)")
2650
+ .option("--project <projectSlug>", "project slug (used when selecting build)")
2651
+ .option("--skip-preflight", "skip automatic preflight checks before upload")
2652
+ .option("--yes", "skip interactive permission confirmation")
2653
+ .option("--track <track>", "testflight or appstore", "testflight")
2654
+ .option("--api <baseUrl>", "API base URL")
2655
+ .action(async (options) => {
2656
+ ensureSignedIn();
2657
+ const ui = new CliUi("iOS Submission Start");
2658
+ const apiBaseUrl = getApiBaseUrl(options.api);
2659
+ let buildId = options.build;
2660
+ let ipaPath;
2661
+ let orgSlug = inferOrgSlug(options.org).value;
2662
+ let projectSlug = inferProjectSlug(options.project).value;
2663
+ if (options.ipa) {
2664
+ ipaPath = await ui.step("Validating IPA path", async () => normalizeIpaPath(options.ipa));
2665
+ }
2666
+ if (!buildId && !ipaPath) {
2667
+ const filterOrg = options.org;
2668
+ const filterProject = options.project;
2669
+ const succeededBuilds = await ui.step("Loading recent builds", async () => listRecentSucceededBuilds({
2670
+ orgSlug: filterOrg,
2671
+ projectSlug: filterProject,
2672
+ limit: 25
2673
+ }));
2674
+ if (succeededBuilds.length === 0) {
2675
+ throw new Error("No succeeded builds found. Run `gl build ios start` first.");
2676
+ }
2677
+ const selectedBuild = await ui.step("Selecting build", async () => promptForBuildSelection(succeededBuilds));
2678
+ buildId = selectedBuild.id;
2679
+ orgSlug = selectedBuild.orgSlug;
2680
+ projectSlug = selectedBuild.projectSlug;
2681
+ }
2682
+ if (buildId) {
2683
+ const selectedBuild = localState.builds.find((item) => item.id === buildId);
2684
+ if (selectedBuild) {
2685
+ orgSlug = selectedBuild.orgSlug;
2686
+ projectSlug = selectedBuild.projectSlug;
2687
+ }
2688
+ }
2689
+ ui.info(`API: ${apiBaseUrl}`);
2690
+ if (buildId) {
2691
+ ui.info(`Build ID: ${buildId}`);
2692
+ }
2693
+ ui.info(`Organization: ${orgSlug}`);
2694
+ ui.info(`Project: ${projectSlug}`);
2695
+ if (ipaPath) {
2696
+ ui.info(`IPA Path: ${ipaPath}`);
2697
+ }
2698
+ ui.info(`Track: ${options.track}`);
2699
+ ui.info("Submission upload can take several minutes.");
2700
+ if (!options.skipPreflight) {
2701
+ ui.info("Automatic preflight is enabled.");
2702
+ await ui.step("Running preflight checks", async () => {
2703
+ const args = [
2704
+ process.argv[1],
2705
+ "submit",
2706
+ "ios",
2707
+ "preflight",
2708
+ "--api",
2709
+ apiBaseUrl,
2710
+ "--org",
2711
+ orgSlug,
2712
+ "--project",
2713
+ projectSlug
2714
+ ];
2715
+ if (buildId) {
2716
+ args.push("--build", buildId);
2717
+ }
2718
+ if (ipaPath) {
2719
+ args.push("--ipa", ipaPath);
2720
+ }
2721
+ await runCommand(process.execPath, args, { inherit: true });
2722
+ });
2723
+ }
2724
+ else {
2725
+ ui.info("Skipping preflight checks (--skip-preflight).");
2726
+ }
2727
+ const submissionConsentText = buildId
2728
+ ? `Allow Green Line to upload build ${buildId} to App Store Connect (${options.track}) for ${orgSlug}/${projectSlug}?`
2729
+ : `Allow Green Line to upload the selected IPA to App Store Connect (${options.track}) for ${orgSlug}/${projectSlug}?`;
2730
+ const submissionConsentAccepted = options.yes
2731
+ ? true
2732
+ : await ui.step("Requesting permission for submission upload", async () => promptForYes(submissionConsentText));
2733
+ await ui.step("Recording permission choice", async () => recordConsent(apiBaseUrl, {
2734
+ orgSlug,
2735
+ projectSlug,
2736
+ action: "ios_submission_start",
2737
+ consentText: submissionConsentText,
2738
+ response: submissionConsentAccepted ? "accepted" : "declined"
2739
+ }));
2740
+ if (!submissionConsentAccepted) {
2741
+ ui.complete("Submission cancelled by user.");
2742
+ console.log("submission_aborted=true");
2743
+ return;
2744
+ }
2745
+ const result = await ui.step("Creating submission job", async () => postJson(`${apiBaseUrl}/v1/submissions`, {
2746
+ buildId,
2747
+ ipaPath,
2748
+ orgSlug,
2749
+ projectSlug,
2750
+ track: options.track
2751
+ }));
2752
+ ui.complete("Submission job created.");
2753
+ console.log(`submission_id=${result.submission.id}`);
2754
+ console.log(`status=${result.submission.status}`);
2755
+ console.log(`track=${result.submission.track}`);
2756
+ console.log(`created_at=${result.submission.createdAt}`);
2757
+ });
2758
+ submitIos
2759
+ .command("list")
2760
+ .option("--org <orgSlug>", "organization slug (auto-resolved if omitted)")
2761
+ .option("--project <projectSlug>", "project slug (auto-resolved if omitted)")
2762
+ .option("--limit <count>", "result limit", "20")
2763
+ .option("--api <baseUrl>", "API base URL")
2764
+ .action(async (options) => {
2765
+ const ui = new CliUi("iOS Submission List");
2766
+ const apiBaseUrl = getApiBaseUrl(options.api);
2767
+ const org = inferOrgSlug(options.org);
2768
+ const project = inferProjectSlug(options.project);
2769
+ ui.info(`API: ${apiBaseUrl}`);
2770
+ ui.info(`Organization: ${org.value} (${org.source})`);
2771
+ ui.info(`Project: ${project.value} (${project.source})`);
2772
+ ui.info(`Limit: ${options.limit}`);
2773
+ const url = withQuery(`${apiBaseUrl}/v1/submissions`, {
2774
+ orgSlug: org.value,
2775
+ projectSlug: project.value,
2776
+ limit: Number(options.limit)
2777
+ });
2778
+ const result = await ui.step("Loading submissions", async () => getJson(url));
2779
+ ui.complete("Submission list loaded.");
2780
+ for (const item of result.submissions) {
2781
+ console.log(`${item.id}\t${item.status}\t${item.track}\t${item.buildId}\t${item.createdAt}`);
2782
+ }
2783
+ });
2784
+ submitIos
2785
+ .command("status <submissionId>")
2786
+ .option("--api <baseUrl>", "API base URL")
2787
+ .action(async (submissionId, options) => {
2788
+ const ui = new CliUi("iOS Submission Status");
2789
+ const apiBaseUrl = getApiBaseUrl(options.api);
2790
+ ui.info(`API: ${apiBaseUrl}`);
2791
+ ui.info(`Submission ID: ${submissionId}`);
2792
+ const result = await ui.step("Loading submission status", async () => getJson(`${apiBaseUrl}/v1/submissions/${submissionId}`));
2793
+ ui.complete("Submission status loaded.");
2794
+ console.log(`submission_id=${result.submission.id}`);
2795
+ console.log(`status=${result.submission.status}`);
2796
+ console.log(`track=${result.submission.track}`);
2797
+ if (result.submission.externalSubmissionId) {
2798
+ console.log(`external_submission_id=${result.submission.externalSubmissionId}`);
2799
+ }
2800
+ if (result.submission.errorMessage) {
2801
+ console.log(`error=${result.submission.errorMessage}`);
2802
+ }
2803
+ console.log(`updated_at=${result.submission.updatedAt}`);
2804
+ });
2805
+ submitIos
2806
+ .command("logs <submissionId>")
2807
+ .option("--api <baseUrl>", "API base URL")
2808
+ .action(async (submissionId, options) => {
2809
+ const ui = new CliUi("iOS Submission Logs");
2810
+ const apiBaseUrl = getApiBaseUrl(options.api);
2811
+ ui.info(`API: ${apiBaseUrl}`);
2812
+ ui.info(`Submission ID: ${submissionId}`);
2813
+ const result = await ui.step("Loading submission logs", async () => getJson(`${apiBaseUrl}/v1/submissions/${submissionId}/logs`));
2814
+ ui.complete("Submission logs loaded.");
2815
+ process.stdout.write(result.logs);
2816
+ });
2817
+ program.parseAsync(process.argv).catch((error) => {
2818
+ console.error(error.message);
2819
+ process.exit(1);
2820
+ });
2821
+ //# sourceMappingURL=index.js.map