ant-go 0.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,687 @@
1
+ /**
2
+ * apple-creds.js — Thu thập Apple credentials phía CLI (interactive, hỗ trợ 2FA)
3
+ *
4
+ * Hỗ trợ 2 loại distribution:
5
+ * store — Distribution cert + App Store Provisioning Profile
6
+ * internal — Development cert + Development Provisioning Profile (cần UDID device)
7
+ *
8
+ * Cache per profile tại: ~/.ant-go/creds-<profileName>.json
9
+ */
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const os = require('os');
14
+ const chalk = require('chalk');
15
+ const inquirer = require('inquirer');
16
+ const axios = require('axios');
17
+ const qrcode = require('qrcode-terminal');
18
+ const { API_URL } = require('./config');
19
+ const { t } = require('./i18n');
20
+
21
+ const CACHE_DIR = path.join(os.homedir(), '.ant-go');
22
+ const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 giờ
23
+
24
+ // ── Capability detection ──────────────────────────────────────────────────────
25
+ // Maps entitlement key → ASC CapabilityType value
26
+ const ENTITLEMENT_TO_CAPABILITY = {
27
+ 'aps-environment': 'PUSH_NOTIFICATIONS',
28
+ 'com.apple.developer.associated-domains': 'ASSOCIATED_DOMAINS',
29
+ 'com.apple.security.application-groups': 'APP_GROUPS',
30
+ 'com.apple.developer.in-app-payments': 'APPLE_PAY',
31
+ 'com.apple.developer.healthkit': 'HEALTHKIT',
32
+ 'com.apple.developer.homekit': 'HOMEKIT',
33
+ 'com.apple.developer.icloud-container-identifiers': 'ICLOUD',
34
+ 'com.apple.developer.siri': 'SIRIKIT',
35
+ 'com.apple.developer.networking.networkextension': 'NETWORK_EXTENSIONS',
36
+ 'com.apple.developer.nfc.readersession.formats': 'NFC_TAG_READING',
37
+ 'com.apple.developer.pass-type-identifiers': 'WALLET',
38
+ 'com.apple.developer.personal-vpn': 'PERSONAL_VPN',
39
+ 'com.apple.developer.authentication-services.autofill-credential-provider': 'AUTOFILL_CREDENTIAL_PROVIDER',
40
+ 'com.apple.developer.ClassKit-environment': 'CLASSKIT',
41
+ 'com.apple.developer.default-data-protection': 'DATA_PROTECTION',
42
+ 'com.apple.developer.maps': 'MAPS',
43
+ 'com.apple.developer.weatherkit': 'WEATHERKIT',
44
+ 'com.apple.developer.game-center': 'GAME_CENTER',
45
+ 'com.apple.developer.pushkit.access': 'PUSH_TO_TALK',
46
+ };
47
+
48
+ function findEntitlementsFile(iosDir) {
49
+ if (!fs.existsSync(iosDir)) return null;
50
+ // Search top-level and one level deep inside ios/
51
+ const entries = fs.readdirSync(iosDir, { withFileTypes: true });
52
+ for (const e of entries) {
53
+ if (e.isFile() && e.name.endsWith('.entitlements')) return path.join(iosDir, e.name);
54
+ if (e.isDirectory()) {
55
+ try {
56
+ const sub = fs.readdirSync(path.join(iosDir, e.name), { withFileTypes: true });
57
+ for (const s of sub) {
58
+ if (s.isFile() && s.name.endsWith('.entitlements')) return path.join(iosDir, e.name, s.name);
59
+ }
60
+ } catch {}
61
+ }
62
+ }
63
+ return null;
64
+ }
65
+
66
+ function parseEntitlementKeys(content) {
67
+ const keys = [];
68
+ const regex = /<key>([^<]+)<\/key>/g;
69
+ let match;
70
+ while ((match = regex.exec(content)) !== null) keys.push(match[1].trim());
71
+ return keys;
72
+ }
73
+
74
+ async function syncCapabilities(authCtx, bundleIdObj, entitlementKeys) {
75
+ const { CapabilityTypeOption } = require('@expo/apple-utils');
76
+
77
+ const required = [...new Set(entitlementKeys.map(k => ENTITLEMENT_TO_CAPABILITY[k]).filter(Boolean))];
78
+
79
+ let current = [];
80
+ try {
81
+ const caps = await bundleIdObj.getBundleIdCapabilitiesAsync();
82
+ current = (caps ?? []).map(c => c.attributes?.capabilityType).filter(Boolean);
83
+ } catch {}
84
+
85
+ const toEnable = required.filter(c => !current.includes(c));
86
+ const failed = [];
87
+ for (const capType of toEnable) {
88
+ try {
89
+ await bundleIdObj.updateBundleIdCapabilityAsync({ capabilityType: capType, option: CapabilityTypeOption.ON });
90
+ } catch (err) {
91
+ failed.push({ capType, reason: err.message });
92
+ }
93
+ }
94
+
95
+ if (failed.length > 0) {
96
+ console.log(chalk.yellow(` ⚠ Could not enable: ${failed.map(f => f.capType).join(', ')}`));
97
+ }
98
+
99
+ return [...new Set([...current, ...toEnable.filter(c => !failed.find(f => f.capType === c))])];
100
+ }
101
+
102
+ async function getCurrentCapabilities(bundleIdObj) {
103
+ try {
104
+ const caps = await bundleIdObj.getBundleIdCapabilitiesAsync();
105
+ return (caps ?? []).map(c => c.attributes?.capabilityType).filter(Boolean);
106
+ } catch {
107
+ return [];
108
+ }
109
+ }
110
+
111
+ function getCacheFile(profileName) {
112
+ return path.join(CACHE_DIR, `creds-${profileName}.json`);
113
+ }
114
+
115
+ function loadCache(profileName) {
116
+ const file = getCacheFile(profileName);
117
+ if (!fs.existsSync(file)) return null;
118
+ try {
119
+ const d = JSON.parse(fs.readFileSync(file, 'utf8'));
120
+ if (Date.now() - d._savedAt > CACHE_TTL) { fs.unlinkSync(file); return null; }
121
+ return d;
122
+ } catch { return null; }
123
+ }
124
+
125
+ function saveCache(creds, profileName) {
126
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
127
+ fs.writeFileSync(
128
+ getCacheFile(profileName),
129
+ JSON.stringify({ ...creds, _savedAt: Date.now() }, null, 2),
130
+ { mode: 0o600 }
131
+ );
132
+ }
133
+
134
+ function clearCache(profileName) {
135
+ const file = getCacheFile(profileName);
136
+ if (fs.existsSync(file)) fs.unlinkSync(file);
137
+ }
138
+
139
+ // ── Device enrollment via .mobileconfig ──────────────────────────────────────
140
+ // Returns { udid, deviceProduct, deviceSerial }
141
+ async function enrollDevice(projectId, apiClient) {
142
+ console.log('');
143
+ console.log(chalk.cyan(t('enrollNewDevice')));
144
+ console.log(chalk.gray(t('enrollQRHint')));
145
+ console.log('');
146
+
147
+ let token, enrollUrl;
148
+ try {
149
+ const res = await apiClient.post(`/api/device-enroll/create`, { projectId });
150
+ token = res.data.token;
151
+ enrollUrl = res.data.enrollUrl;
152
+ } catch (err) {
153
+ throw new Error(t('enrollCreateFailed', err.response?.data?.error || err.message));
154
+ }
155
+
156
+ console.log(chalk.bold(t('enrollQRScan')));
157
+ console.log('');
158
+ await new Promise(resolve => qrcode.generate(enrollUrl, { small: true }, (qr) => {
159
+ console.log(qr);
160
+ resolve();
161
+ }));
162
+ console.log('');
163
+ console.log(chalk.gray(t('enrollOrOpen')) + chalk.underline(enrollUrl));
164
+ console.log('');
165
+ console.log(chalk.yellow(t('enrollWaiting')));
166
+
167
+ const POLL_INTERVAL = 3000;
168
+ const TIMEOUT = 10 * 60 * 1000;
169
+ const deadline = Date.now() + TIMEOUT;
170
+ const spinner = require('ora')('').start();
171
+
172
+ while (Date.now() < deadline) {
173
+ await new Promise(r => setTimeout(r, POLL_INTERVAL));
174
+ try {
175
+ const res = await apiClient.get(`/api/device-enroll/${token}/status`);
176
+ const { status, udid, deviceProduct, deviceSerial } = res.data;
177
+ if (status === 'registered' && udid) {
178
+ spinner.succeed(chalk.green(t('enrollConfirmed', deviceProduct || udid, udid)));
179
+ return { udid, deviceProduct: deviceProduct || null, deviceSerial: deviceSerial || null };
180
+ }
181
+ if (status === 'expired') {
182
+ spinner.fail(t('enrollExpired'));
183
+ throw new Error('Device enrollment timeout');
184
+ }
185
+ const remaining = Math.ceil((deadline - Date.now()) / 1000 / 60);
186
+ spinner.text = t('enrollPolling', remaining);
187
+ } catch (err) {
188
+ if (err.message === 'Device enrollment timeout') throw err;
189
+ }
190
+ }
191
+
192
+ spinner.fail(t('enrollTimeout'));
193
+ throw new Error('Device enrollment timeout');
194
+ }
195
+
196
+ // ── ASC API Key (App Store Connect) ──────────────────────────────────────────
197
+ // Dùng cùng Apple Developer Portal session (authCtx) đã có sau khi login để
198
+ // tạo + download ASC API Key (.p8). Cache tại ~/.ant-go/asc-key-{teamId}.json
199
+ // (không có TTL vì key không tự hết hạn).
200
+ async function ensureAscKey(authCtx, teamId) {
201
+ const { ApiKey, ApiKeyType } = require('@expo/apple-utils');
202
+ const ora = require('ora');
203
+ const cacheFile = path.join(CACHE_DIR, `asc-key-${teamId}.json`);
204
+
205
+ // 1. Đọc cache
206
+ let cached = null;
207
+ if (fs.existsSync(cacheFile)) {
208
+ try { cached = JSON.parse(fs.readFileSync(cacheFile, 'utf8')); } catch {}
209
+ }
210
+
211
+ // 2. Nếu có cache → verify key còn tồn tại trên Apple
212
+ if (cached?.keyId && cached?.privateKeyP8) {
213
+ try {
214
+ const existingKeys = await ApiKey.getAsync(authCtx) ?? [];
215
+ const still = existingKeys.find(k => k.id === cached.keyId);
216
+ if (still) {
217
+ console.log(chalk.green(t('ascKeyCached', cached.keyId)));
218
+ return cached;
219
+ }
220
+ // Key bị revoke trên Apple → xoá cache, tạo mới
221
+ fs.unlinkSync(cacheFile);
222
+ } catch {
223
+ // Không verify được (mạng / quyền) → dùng cache
224
+ return cached;
225
+ }
226
+ }
227
+
228
+ // 3. Tạo key mới
229
+ const spinner = ora(t('ascKeyCreating')).start();
230
+ try {
231
+ const newKey = await ApiKey.createAsync(authCtx, {
232
+ nickname: 'ant-go',
233
+ roles: ['ADMIN'],
234
+ allAppsVisible: true,
235
+ keyType: ApiKeyType.PUBLIC_API,
236
+ });
237
+
238
+ const privateKeyP8 = await newKey.downloadAsync();
239
+ if (!privateKeyP8) throw new Error('Download .p8 trả về rỗng');
240
+
241
+ // 4. Lấy issuerId từ provider info
242
+ let issuerId = null;
243
+ try {
244
+ const keys = await ApiKey.getAsync(authCtx) ?? [];
245
+ issuerId = keys[0]?.attributes?.provider?.id ?? null;
246
+ } catch {}
247
+
248
+ if (!issuerId) {
249
+ spinner.stop();
250
+ console.log(chalk.yellow('\n' + t('ascKeyNoIssuer')));
251
+ console.log(chalk.gray(t('ascKeyIssuerHint')));
252
+ const { inputIssuerId } = await inquirer.prompt([{
253
+ type: 'input',
254
+ name: 'inputIssuerId',
255
+ message: t('ascKeyIssuerLabel'),
256
+ validate: v => v.trim() ? true : t('appleRequired'),
257
+ }]);
258
+ issuerId = inputIssuerId.trim();
259
+ spinner.start();
260
+ }
261
+
262
+ const result = { keyId: newKey.id, issuerId, privateKeyP8, _savedAt: Date.now() };
263
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
264
+ fs.writeFileSync(cacheFile, JSON.stringify(result, null, 2), { mode: 0o600 });
265
+ spinner.succeed(t('ascKeyCreated', newKey.id));
266
+ return result;
267
+
268
+ } catch (err) {
269
+ spinner.warn(t('ascKeyFailed', err.message));
270
+ return null;
271
+ }
272
+ }
273
+
274
+ // ── Multi-select device UI ────────────────────────────────────────────────────
275
+ // Hiển thị danh sách device đã có + option thêm mới.
276
+ // Returns: string[] — mảng các UDID được chọn
277
+ async function selectDevices(existingDevices, projectId, apiClient) {
278
+ const devices = [...existingDevices];
279
+
280
+ // Nếu chưa có device nào → đi thẳng vào enrollment
281
+ if (devices.length === 0) {
282
+ console.log(chalk.gray(' ' + t('appleDevicesNone')));
283
+ const enrolled = await enrollDevice(projectId, apiClient);
284
+ const { deviceName } = await inquirer.prompt([{
285
+ type: 'input',
286
+ name: 'deviceName',
287
+ message: t('appleDeviceName'),
288
+ default: t('appleDeviceDefault'),
289
+ }]);
290
+ await apiClient.post('/api/devices', {
291
+ udid: enrolled.udid,
292
+ name: deviceName,
293
+ deviceProduct: enrolled.deviceProduct,
294
+ deviceSerial: enrolled.deviceSerial,
295
+ source: 'cli',
296
+ });
297
+ return [enrolled.udid];
298
+ }
299
+
300
+ // Vòng lặp: hiển thị multi-select, xử lý "Thêm device mới"
301
+ while (true) {
302
+ const choices = [
303
+ ...devices.map(d => ({
304
+ name: `${(d.name || 'Unnamed').padEnd(18)} ${(d.deviceProduct || '').padEnd(12)} (${d.udid.slice(0, 12)}...)`,
305
+ value: d.udid,
306
+ checked: true,
307
+ })),
308
+ new inquirer.Separator('─────────────────────────────────────────'),
309
+ { name: chalk.cyan(t('appleDevicesAddNew')), value: '__new__' },
310
+ ];
311
+
312
+ console.log('');
313
+ const { selected } = await inquirer.prompt([{
314
+ type: 'checkbox',
315
+ name: 'selected',
316
+ message: t('appleDevicesLabel'),
317
+ choices,
318
+ validate: v => v.length > 0 ? true : t('appleDevicesMustSelect'),
319
+ }]);
320
+
321
+ if (!selected.includes('__new__')) return selected;
322
+
323
+ // Thêm device mới → enroll → lưu Firestore → quay lại chọn
324
+ const enrolled = await enrollDevice(projectId, apiClient);
325
+ const { deviceName } = await inquirer.prompt([{
326
+ type: 'input',
327
+ name: 'deviceName',
328
+ message: t('appleDeviceName'),
329
+ default: t('appleDeviceDefault'),
330
+ }]);
331
+ const saveSpinner = require('ora')(t('appleDeviceSaving')).start();
332
+ try {
333
+ await apiClient.post('/api/devices', {
334
+ udid: enrolled.udid,
335
+ name: deviceName,
336
+ deviceProduct: enrolled.deviceProduct,
337
+ deviceSerial: enrolled.deviceSerial,
338
+ source: 'cli',
339
+ });
340
+ saveSpinner.succeed(t('appleDeviceSaved', deviceName));
341
+ } catch (err) {
342
+ saveSpinner.fail(t('appleDeviceSaveFailed', err.response?.data?.error || err.message));
343
+ }
344
+
345
+ devices.push({ udid: enrolled.udid, name: deviceName, deviceProduct: enrolled.deviceProduct, deviceSerial: enrolled.deviceSerial });
346
+ }
347
+ }
348
+
349
+ // ── Public entry ──────────────────────────────────────────────────────────────
350
+ async function ensureAppleCreds(projectInfo, {
351
+ force = false,
352
+ refreshProfile = false,
353
+ distribution = 'store',
354
+ profileName = 'production',
355
+ userDevices = [],
356
+ apiClient = null,
357
+ projectRoot = null, // used for entitlements-based capability sync
358
+ _preselectedUdids = null, // internal use: skip selectDevices when already chosen
359
+ } = {}) {
360
+
361
+ // ── Cache check ─────────────────────────────────────────────────────────────
362
+ if (!force && !refreshProfile) {
363
+ const cached = loadCache(profileName);
364
+ if (cached) {
365
+ if (!cached.appleId) {
366
+ clearCache(profileName);
367
+ } else {
368
+ const email = cached.appleId;
369
+ const teamId = cached.teamId || '';
370
+ const cachedUdids = cached.udids ?? (cached.udid ? [cached.udid] : []);
371
+ const udidHint = distribution === 'internal' && cachedUdids.length > 0
372
+ ? ` Devices: ${cachedUdids.length}` : '';
373
+ const label = [email, teamId ? `(${teamId})` : '', udidHint].filter(Boolean).join(' ');
374
+ console.log('');
375
+ const { useCache } = await inquirer.prompt([{
376
+ type: 'list',
377
+ name: 'useCache',
378
+ message: t('appleLoginPrompt'),
379
+ choices: [
380
+ { name: t('appleUseCache', label), value: true },
381
+ { name: t('appleLoginOther'), value: false },
382
+ ],
383
+ }]);
384
+
385
+ if (useCache) {
386
+ // internal: luôn hỏi chọn device, dù dùng cache
387
+ if (distribution === 'internal') {
388
+ // Merge cached udids vào userDevices để hiện đủ danh sách dù Firestore chưa có
389
+ const cachedDevices = cachedUdids
390
+ .filter(u => !userDevices.find(d => d.udid === u))
391
+ .map(u => ({ udid: u, name: 'Device (cached)', deviceProduct: null }));
392
+ const mergedDevices = [...userDevices, ...cachedDevices];
393
+
394
+ const selectedUdids = await selectDevices(mergedDevices, projectInfo.projectId, apiClient);
395
+ const sameDevices = selectedUdids.length === cachedUdids.length
396
+ && selectedUdids.every(u => cachedUdids.includes(u));
397
+
398
+ if (sameDevices) {
399
+ return { ...cached, udids: selectedUdids, ...projectInfo };
400
+ } else {
401
+ clearCache(profileName);
402
+ return await ensureAppleCreds(projectInfo, {
403
+ force: true, refreshProfile: true,
404
+ distribution, profileName,
405
+ userDevices, apiClient,
406
+ _preselectedUdids: selectedUdids,
407
+ });
408
+ }
409
+ }
410
+ return { ...cached, ...projectInfo };
411
+ }
412
+ clearCache(profileName);
413
+ }
414
+ }
415
+ }
416
+
417
+ const {
418
+ Auth, Teams, Certificate, CertificateType,
419
+ createCertificateAndP12Async, BundleId, Profile, ProfileType, Device,
420
+ } = require('@expo/apple-utils');
421
+
422
+ console.log('');
423
+ console.log(chalk.yellow.bold(t('appleCredsLogin')));
424
+ console.log('');
425
+
426
+ // ── Apple ID + password ──────────────────────────────────────────────────────
427
+ const { appleId, password } = await inquirer.prompt([
428
+ {
429
+ type: 'input',
430
+ name: 'appleId',
431
+ message: t('appleIdLabel'),
432
+ validate: v => v.trim() ? true : t('appleRequired'),
433
+ },
434
+ {
435
+ type: 'password',
436
+ name: 'password',
437
+ message: t('applePasswordLabel'),
438
+ mask: '•',
439
+ validate: v => v.trim() ? true : t('appleRequired'),
440
+ },
441
+ ]);
442
+
443
+ console.log('');
444
+
445
+ // Không dùng spinner ở đây vì @expo/apple-utils có spinner nội bộ riêng.
446
+ // Nếu cả hai cùng chạy, spinner của chúng ta sẽ overwrite readline prompt 2FA.
447
+ let authCtx, teamId;
448
+ try {
449
+ const result = await Auth.loginAsync({
450
+ username: appleId.trim(),
451
+ password: password.trim(),
452
+ }, {
453
+ serviceKey: undefined,
454
+ onTwoFactorRequest: async () => {
455
+ // Apple-utils đã dừng spinner nội bộ của nó trước khi gọi callback này.
456
+ // Dùng readline trực tiếp — đơn giản và không bị xung đột terminal.
457
+ process.stdout.write('\n🔐 ' + t('apple2FA') + ' ');
458
+
459
+ const readline = require('readline');
460
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
461
+ const code = await new Promise(resolve => rl.on('line', line => { rl.close(); resolve(line.trim()); }));
462
+
463
+ console.log(chalk.gray(t('apple2FAVerifying')));
464
+ return code;
465
+ },
466
+ });
467
+ authCtx = result.context ?? result;
468
+ console.log(chalk.green('✔ ' + t('appleLoginSuccess')));
469
+ } catch (err) {
470
+ console.log(chalk.red('✖ ' + t('appleLoginFailed', err.message)));
471
+ throw err;
472
+ }
473
+
474
+ // ── Team selection ───────────────────────────────────────────────────────────
475
+ const teamSpinner = require('ora')(t('appleLoadingTeam')).start();
476
+ try {
477
+ const teams = await Teams.getTeamsAsync(authCtx);
478
+ teamSpinner.stop();
479
+ if (!teams || teams.length === 0) throw new Error(t('appleNoTeam'));
480
+
481
+ if (teams.length === 1) {
482
+ teamId = teams[0].teamId;
483
+ console.log(chalk.green(`✔ Team: ${teams[0].name} (${teamId})`));
484
+ } else {
485
+ const { selectedTeam } = await inquirer.prompt([{
486
+ type: 'list',
487
+ name: 'selectedTeam',
488
+ message: t('appleSelectTeam'),
489
+ choices: teams.map(tm => ({ name: `${tm.name} (${tm.teamId}) — ${tm.type}`, value: tm.teamId })),
490
+ }]);
491
+ teamId = selectedTeam;
492
+ }
493
+ await Teams.selectTeamAsync(authCtx, { teamId });
494
+ } catch (err) {
495
+ teamSpinner.fail(t('appleTeamFailed', err.message));
496
+ throw err;
497
+ }
498
+
499
+ // ── ASC API Key (chỉ store distribution) ────────────────────────────────────
500
+ let ascKey = null;
501
+ if (distribution === 'store') {
502
+ ascKey = await ensureAscKey(authCtx, teamId);
503
+ }
504
+
505
+ // ── Device selection + enrollment (internal only) ────────────────────────────
506
+ let selectedUdids = [];
507
+ let deviceIds = [];
508
+ if (distribution === 'internal') {
509
+ selectedUdids = _preselectedUdids ?? await selectDevices(userDevices, projectInfo.projectId, apiClient);
510
+
511
+ // Đồng bộ từng UDID lên Apple Developer Portal
512
+ const appleDevicesAll = await Device.getAsync(authCtx, {});
513
+ const syncSpinner = require('ora')(t('appleDeviceSyncing', selectedUdids.length)).start();
514
+ try {
515
+ for (const udid of selectedUdids) {
516
+ const existing = appleDevicesAll.find(
517
+ d => d.attributes?.udid?.toLowerCase() === udid.toLowerCase()
518
+ );
519
+ if (existing) {
520
+ deviceIds.push(existing.id);
521
+ } else {
522
+ const deviceEntry = userDevices.find(d => d.udid === udid);
523
+ const newDevice = await Device.createAsync(authCtx, {
524
+ name: deviceEntry?.name || t('appleDeviceDefault'), udid, platform: 'IOS',
525
+ });
526
+ deviceIds.push(newDevice.id);
527
+ }
528
+ }
529
+ syncSpinner.succeed(t('appleDeviceSynced', deviceIds.length));
530
+ } catch (err) {
531
+ syncSpinner.fail(t('appleDeviceSyncFailed', err.message));
532
+ throw err;
533
+ }
534
+ }
535
+
536
+ // ── Certificate ──────────────────────────────────────────────────────────────
537
+ // Ad Hoc and App Store both use Distribution certificate
538
+ const certType = CertificateType.DISTRIBUTION;
539
+ const certLabel = distribution === 'internal' ? 'Distribution (Ad Hoc)' : 'Distribution';
540
+ const certSpinner = require('ora')(t('certLoading', certLabel)).start();
541
+ let p12Base64, p12Password, certId;
542
+ let createdNewCert = false;
543
+ try {
544
+ const existing = await Certificate.getAsync(authCtx, {
545
+ query: { filter: { certificateType: [certType] } },
546
+ });
547
+
548
+ if (existing.length > 0) {
549
+ const result = await createCertificateAndP12Async(authCtx, {
550
+ certificateType: certType, reuseExistingCertificate: true,
551
+ }).catch(() => null);
552
+ if (result) {
553
+ // Use certId from the result — createCertificateAndP12Async may pick
554
+ // a different cert than existing[0] (whichever has a private key locally).
555
+ // certId and p12 must always refer to the same certificate.
556
+ certId = result.certificate?.id ?? existing[0].id;
557
+ p12Base64 = result.certificateP12;
558
+ p12Password = result.password ?? '';
559
+ certSpinner.succeed(t('certReused', certLabel, certId));
560
+ } else {
561
+ // Private key không còn local → revoke TẤT CẢ cert cũ rồi tạo mới.
562
+ // Phải xoá hết vì Apple không cho tạo mới khi còn cert active.
563
+ certSpinner.text = `${t('certLoading', certLabel)} (revoking ${existing.length} old cert(s)...)`;
564
+ for (const cert of existing) {
565
+ try {
566
+ await Certificate.deleteAsync(authCtx, { id: cert.id });
567
+ } catch (delErr) {
568
+ certSpinner.warn(`Could not revoke cert ${cert.id}: ${delErr.message}`);
569
+ certSpinner.start();
570
+ }
571
+ }
572
+ const newResult = await createCertificateAndP12Async(authCtx, { certificateType: certType });
573
+ const certsAfter = await Certificate.getAsync(authCtx, { query: { filter: { certificateType: [certType] } } });
574
+ const existingIds = new Set(existing.map(c => c.id));
575
+ const newCert = certsAfter.find(c => !existingIds.has(c.id));
576
+ certId = newCert?.id ?? newResult.certificate?.id;
577
+ p12Base64 = newResult.certificateP12;
578
+ p12Password = newResult.password ?? '';
579
+ createdNewCert = true;
580
+ certSpinner.succeed(t('certNew', certLabel, certId));
581
+ }
582
+ } else {
583
+ const result = await createCertificateAndP12Async(authCtx, { certificateType: certType });
584
+ const certsAfter = await Certificate.getAsync(authCtx, { query: { filter: { certificateType: [certType] } } });
585
+ const newCert = certsAfter.find(c => !existing.find(e => e.id === c.id));
586
+ certId = newCert?.id ?? result.certificate?.id;
587
+ p12Base64 = result.certificateP12;
588
+ p12Password = result.password ?? '';
589
+ createdNewCert = true;
590
+ certSpinner.succeed(t('certNew', certLabel, certId));
591
+ }
592
+ } catch (err) {
593
+ certSpinner.fail(t('certFailed', certLabel, err.message));
594
+ throw err;
595
+ }
596
+
597
+ // ── Provisioning Profile ─────────────────────────────────────────────────────
598
+ const profileType = distribution === 'internal' ? ProfileType.IOS_APP_ADHOC : ProfileType.IOS_APP_STORE;
599
+ const profileLabel = distribution === 'internal' ? 'Ad Hoc' : 'App Store';
600
+ const profileSpinner = require('ora')(t('profileLoading', profileLabel)).start();
601
+ let mobileprovisionBase64;
602
+ try {
603
+ const allBundleIds = await BundleId.getAsync(authCtx, {});
604
+ let bundleIdObj = allBundleIds.find(b => b.attributes?.identifier === projectInfo.bundleId);
605
+ if (!bundleIdObj) {
606
+ profileSpinner.text = t('profileRegisteringId', projectInfo.bundleId);
607
+ bundleIdObj = await BundleId.createAsync(authCtx, {
608
+ name: projectInfo.bundleId,
609
+ identifier: projectInfo.bundleId,
610
+ platform: 'IOS',
611
+ });
612
+ }
613
+
614
+ const allProfiles = await Profile.getAsync(authCtx, {
615
+ query: { filter: { profileType: [profileType] } },
616
+ });
617
+ const existingProfile = allProfiles.find(
618
+ p => p.attributes?.bundleId?.attributes?.identifier === projectInfo.bundleId
619
+ );
620
+
621
+ let profile = null;
622
+
623
+ if (existingProfile?.attributes?.profileState === 'ACTIVE' && !createdNewCert && !refreshProfile) {
624
+ const profileCertIds = (existingProfile.attributes?.certificates ?? []).map(c => c.id);
625
+ if (profileCertIds.includes(certId)) {
626
+ profile = existingProfile;
627
+ profileSpinner.text = t('profileReusing', profileLabel);
628
+ } else {
629
+ profileSpinner.text = t('profileCertMismatch');
630
+ await Profile.deleteAsync(authCtx, { id: existingProfile.id });
631
+ }
632
+ } else if (existingProfile) {
633
+ profileSpinner.text = refreshProfile
634
+ ? t('profileCapChanged')
635
+ : createdNewCert ? t('profileNewCert') : t('profileInvalid');
636
+ await Profile.deleteAsync(authCtx, { id: existingProfile.id });
637
+ }
638
+
639
+ let capabilities = [];
640
+ if (!profile) {
641
+ // Sync capabilities from .entitlements before creating profile (store only)
642
+ if (distribution === 'store' && projectRoot) {
643
+ const entFile = findEntitlementsFile(path.join(projectRoot, 'ios'));
644
+ if (entFile) {
645
+ profileSpinner.text = 'Syncing capabilities from entitlements…';
646
+ try {
647
+ const entKeys = parseEntitlementKeys(fs.readFileSync(entFile, 'utf8'));
648
+ capabilities = await syncCapabilities(authCtx, bundleIdObj, entKeys);
649
+ if (capabilities.length > 0) {
650
+ profileSpinner.text = `Capabilities enabled: ${capabilities.join(', ')}`;
651
+ }
652
+ } catch (err) {
653
+ console.log(chalk.yellow(`\n ⚠ Capability sync skipped: ${err.message}`));
654
+ }
655
+ }
656
+ }
657
+
658
+ const profileName_ = `${profileLabel} ${new Date().toISOString().slice(0, 10)}`;
659
+ profileSpinner.text = t('profileLoading', profileLabel);
660
+ profile = await Profile.createAsync(authCtx, {
661
+ bundleId: bundleIdObj.id, certificates: [certId],
662
+ devices: deviceIds, name: profileName_, profileType,
663
+ });
664
+ } else {
665
+ // Reusing existing — still fetch current capabilities for dashboard display
666
+ capabilities = await getCurrentCapabilities(bundleIdObj);
667
+ }
668
+
669
+ const fresh = await Profile.infoAsync(authCtx, { id: profile.id });
670
+ const data = fresh.attributes?.profileContent ?? profile.attributes?.profileContent;
671
+ if (!data) throw new Error(t('profileNoContent'));
672
+ mobileprovisionBase64 = typeof data === 'string' ? data : Buffer.from(data).toString('base64');
673
+ profileSpinner.succeed(t('profileOK', profileLabel));
674
+
675
+ const creds = { appleId: appleId.trim(), p12Base64, p12Password, mobileprovisionBase64, teamId, udids: selectedUdids, ascKey, capabilities };
676
+ saveCache(creds, profileName);
677
+ console.log(chalk.green(t('credsCached', getCacheFile(profileName))));
678
+ console.log('');
679
+
680
+ return { ...creds, ...projectInfo };
681
+ } catch (err) {
682
+ profileSpinner.fail(t('profileFailed', profileLabel, err.message));
683
+ throw err;
684
+ }
685
+ }
686
+
687
+ module.exports = { ensureAppleCreds, ensureAscKey, loadCache, clearCache, getCacheFile };