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.
- package/README.md +242 -0
- package/bin/ant-go.js +157 -0
- package/docs/add-device-flow.md +207 -0
- package/package.json +24 -0
- package/src/api.js +64 -0
- package/src/apple-creds.js +687 -0
- package/src/commands/auth.js +293 -0
- package/src/commands/build.js +516 -0
- package/src/commands/configure.js +31 -0
- package/src/commands/status.js +50 -0
- package/src/config.js +68 -0
- package/src/i18n.js +584 -0
- package/src/logger.js +14 -0
- package/src/update-check.js +106 -0
|
@@ -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 };
|